feat(blobbi): implement Blobbi Photo (Polaroid) feature
Add the ability to take polaroid-style photos of Blobbis and share them: - Add lookMode prop to Blobbi rendering system: - 'follow-pointer': Eyes track mouse cursor (default, existing behavior) - 'forward': Eyes look straight ahead (for photos/export) - Updated useBlobbiEyes, BlobbiBabyVisual, BlobbiAdultVisual, BlobbiStageVisual - Create BlobbiPolaroidCard component: - Classic polaroid-style frame with white background and shadow - Soft gradient background for photo area - Caption area with Blobbi name, stage, and date - Fixed dimensions (320x400) for consistent export - Built with HTML+CSS (not canvas) for easy customization - Create BlobbiPhotoModal component: - Opens from 'Take a Photo' button on BlobbiPage - Shows polaroid preview with Blobbi looking forward - Download button: exports as PNG using html-to-image - Post button: uploads to Blossom and creates kind 1 note - Clean, minimal UI focused on the photo - Wire up to BlobbiPage: - Photo modal state and handler - Connected to floating 'Take a Photo' action button Dependencies: - Added html-to-image for DOM-to-PNG conversion
This commit is contained in:
Generated
+7
@@ -84,6 +84,7 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
@@ -6257,6 +6258,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/a
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { useBlobbiEyes } from './lib/useBlobbiEyes';
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/types/blobbi';
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface BlobbiAdultVisualProps {
|
||||
blobbi: Blobbi;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: AdultReactionState;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -44,7 +46,7 @@ export interface BlobbiAdultVisualProps {
|
||||
* - Eyes always track the mouse cursor (instant, real-time)
|
||||
* - Renders safely using dangerouslySetInnerHTML
|
||||
*/
|
||||
export function BlobbiAdultVisual({ blobbi, reaction = 'idle', className }: BlobbiAdultVisualProps) {
|
||||
export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', className }: BlobbiAdultVisualProps) {
|
||||
const isSleeping = isBlobbiSleeping(blobbi);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -56,6 +58,7 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', className }: Blob
|
||||
useBlobbiEyes(containerRef, {
|
||||
isSleeping,
|
||||
maxMovement: 2.5, // Slightly more movement for larger adult form
|
||||
lookMode,
|
||||
});
|
||||
|
||||
// Memoize the customized SVG to avoid unnecessary processing
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useMemo, useRef } from 'react';
|
||||
|
||||
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { useBlobbiEyes } from './lib/useBlobbiEyes';
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/types/blobbi';
|
||||
@@ -27,6 +27,8 @@ export interface BlobbiBabyVisualProps {
|
||||
blobbi: Blobbi;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: BabyReactionState;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -41,7 +43,7 @@ export interface BlobbiBabyVisualProps {
|
||||
* - Eyes always track the mouse cursor (instant, real-time)
|
||||
* - Renders safely using dangerouslySetInnerHTML
|
||||
*/
|
||||
export function BlobbiBabyVisual({ blobbi, reaction = 'idle', className }: BlobbiBabyVisualProps) {
|
||||
export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', className }: BlobbiBabyVisualProps) {
|
||||
const isSleeping = isBlobbiSleeping(blobbi);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -53,6 +55,7 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', className }: Blobb
|
||||
useBlobbiEyes(containerRef, {
|
||||
isSleeping,
|
||||
maxMovement: 2,
|
||||
lookMode,
|
||||
});
|
||||
|
||||
// Memoize the customized SVG to avoid unnecessary processing
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
|
||||
*
|
||||
* Features:
|
||||
* - Polaroid-style preview of the Blobbi
|
||||
* - Download as PNG
|
||||
* - Post to Nostr with Blossom upload
|
||||
*
|
||||
* Uses html-to-image for DOM-to-PNG conversion.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Download, Send, Loader2, Camera } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiPhotoModalProps {
|
||||
/** Whether the modal is open */
|
||||
open: boolean;
|
||||
/** Callback when the modal should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi companion to photograph */
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a data URL to a File object
|
||||
*/
|
||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new File([u8arr], filename, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser
|
||||
*/
|
||||
function downloadFile(dataUrl: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPhotoModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
}: BlobbiPhotoModalProps) {
|
||||
const polaroidRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Generate PNG from the polaroid card
|
||||
*/
|
||||
const generateImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!polaroidRef.current) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Could not capture the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use html-to-image with high quality settings
|
||||
const dataUrl = await toPng(polaroidRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // 2x for retina displays
|
||||
cacheBust: true,
|
||||
// Skip external fonts that might fail to load
|
||||
skipFonts: true,
|
||||
});
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to generate the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle download action
|
||||
*/
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const dataUrl = await generateImage();
|
||||
if (dataUrl) {
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
|
||||
downloadFile(dataUrl, filename);
|
||||
toast({
|
||||
title: 'Photo saved!',
|
||||
description: 'Your Blobbi photo has been downloaded.',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [generateImage, companion.name]);
|
||||
|
||||
/**
|
||||
* Handle post action - upload to Blossom and create Nostr post
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to post your Blobbi photo.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
// Generate the image
|
||||
const dataUrl = await generateImage();
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to File for upload
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
|
||||
const file = dataUrlToFile(dataUrl, filename);
|
||||
|
||||
// Upload to Blossom
|
||||
const tags = await uploadFile(file);
|
||||
const [[, url]] = tags;
|
||||
|
||||
// Build imeta tag from NIP-94 tags
|
||||
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
|
||||
|
||||
// Create the post content
|
||||
const content = `${companion.name} ${url}`;
|
||||
|
||||
// Publish kind 1 event
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
tags: [['imeta', ...imetaFields]],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Posted!',
|
||||
description: 'Your Blobbi photo has been shared.',
|
||||
});
|
||||
|
||||
// Close the modal after successful post
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to post:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
|
||||
|
||||
const isProcessing = isGenerating || isPosting;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" />
|
||||
Take a Photo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Capture a polaroid-style photo of {companion.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Polaroid preview - centered */}
|
||||
<div className="flex justify-center py-4">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4 mr-2" />
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={isProcessing || !user}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPosting ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4 mr-2" />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login hint if not logged in */}
|
||||
{!user && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Log in to post your Blobbi photo
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* BlobbiPolaroidCard - Polaroid-style card for Blobbi photos
|
||||
*
|
||||
* Renders a Blobbi inside a classic polaroid-style frame with:
|
||||
* - White/off-white background
|
||||
* - Subtle shadow
|
||||
* - Slightly rounded corners
|
||||
* - Thicker bottom area for name/caption
|
||||
*
|
||||
* Built using HTML + CSS (NOT canvas) for easy customization.
|
||||
* Fixed dimensions ensure consistent export results.
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { BlobbiStageVisual } from './BlobbiStageVisual';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiPolaroidCardProps {
|
||||
/** The Blobbi companion data */
|
||||
companion: BlobbiCompanion;
|
||||
/** Optional caption text (defaults to Blobbi name) */
|
||||
caption?: string;
|
||||
/** Show stage badge (e.g., "Baby", "Adult") */
|
||||
showStage?: boolean;
|
||||
/** Additional CSS classes for the outer container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fixed dimensions for consistent export (3:4 aspect ratio like classic polaroid)
|
||||
const CARD_WIDTH = 320;
|
||||
const CARD_HEIGHT = 400;
|
||||
const PHOTO_AREA_HEIGHT = 300;
|
||||
const CAPTION_AREA_HEIGHT = 100;
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Polaroid-style card for Blobbi photos.
|
||||
*
|
||||
* Uses forwardRef to allow parent components to capture the DOM node
|
||||
* for image export using html-to-image.
|
||||
*/
|
||||
export const BlobbiPolaroidCard = forwardRef<HTMLDivElement, BlobbiPolaroidCardProps>(
|
||||
function BlobbiPolaroidCard({ companion, caption, showStage = false, className }, ref) {
|
||||
const displayCaption = caption ?? companion.name;
|
||||
const stageLabel = companion.stage.charAt(0).toUpperCase() + companion.stage.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-col',
|
||||
'bg-[#fefefe] dark:bg-[#f5f5f0]', // Off-white background
|
||||
'rounded-sm',
|
||||
'shadow-lg',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{/* Photo area with gradient background */}
|
||||
<div
|
||||
className="relative flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
height: PHOTO_AREA_HEIGHT,
|
||||
// Soft gradient background
|
||||
background: 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 50%, #ddd6fe 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Subtle vignette overlay */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.08) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Blobbi visual - forward looking for photo */}
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated={false} // No animations for photo capture
|
||||
lookMode="forward" // Eyes look straight ahead
|
||||
className="size-48 z-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caption area (thicker bottom like classic polaroid) */}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center px-4"
|
||||
style={{ height: CAPTION_AREA_HEIGHT }}
|
||||
>
|
||||
{/* Blobbi name */}
|
||||
<p
|
||||
className="text-xl font-medium text-gray-800 dark:text-gray-800 text-center truncate max-w-full"
|
||||
style={{
|
||||
fontFamily: "'Permanent Marker', 'Comic Sans MS', cursive, sans-serif",
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{displayCaption}
|
||||
</p>
|
||||
|
||||
{/* Optional stage badge */}
|
||||
{showStage && (
|
||||
<span className="mt-1.5 text-xs text-gray-500 dark:text-gray-500 uppercase tracking-wider">
|
||||
{stageLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Optional date or decorative element */}
|
||||
<div className="mt-2 text-[10px] text-gray-400 dark:text-gray-400">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -18,6 +18,9 @@ import { FloatingMusicNotes } from './FloatingMusicNotes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import type { BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
|
||||
export type { BlobbiLookMode };
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +41,8 @@ export interface BlobbiStageVisualProps {
|
||||
animated?: boolean;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: BlobbiReaction;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -108,6 +113,7 @@ export function BlobbiStageVisual({
|
||||
size = 'md',
|
||||
animated = false,
|
||||
reaction = 'idle',
|
||||
lookMode = 'follow-pointer',
|
||||
className,
|
||||
}: BlobbiStageVisualProps) {
|
||||
const { stage } = companion;
|
||||
@@ -157,6 +163,7 @@ export function BlobbiStageVisual({
|
||||
<BlobbiBabyVisual
|
||||
blobbi={blobbiForVisual}
|
||||
reaction={effectiveReaction}
|
||||
lookMode={lookMode}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
@@ -177,6 +184,7 @@ export function BlobbiStageVisual({
|
||||
<BlobbiAdultVisual
|
||||
blobbi={blobbiForVisual}
|
||||
reaction={effectiveReaction}
|
||||
lookMode={lookMode}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
|
||||
@@ -33,11 +33,20 @@ export interface EyePosition {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls how the Blobbi's eyes behave
|
||||
* - 'follow-pointer': Eyes track the mouse cursor (default)
|
||||
* - 'forward': Eyes look straight ahead (for photos/export)
|
||||
*/
|
||||
export type BlobbiLookMode = 'follow-pointer' | 'forward';
|
||||
|
||||
export interface UseBlobbiEyesOptions {
|
||||
/** Whether the Blobbi is sleeping (disables animation) */
|
||||
isSleeping?: boolean;
|
||||
/** Maximum eye movement in pixels (default: 2) */
|
||||
maxMovement?: number;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
@@ -198,7 +207,7 @@ export function useBlobbiEyes(
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
options: UseBlobbiEyesOptions = {}
|
||||
): void {
|
||||
const { isSleeping = false, maxMovement = DEFAULT_MAX_MOVEMENT } = options;
|
||||
const { isSleeping = false, maxMovement = DEFAULT_MAX_MOVEMENT, lookMode = 'follow-pointer' } = options;
|
||||
|
||||
// Animation frame ref for cleanup
|
||||
const animationRef = useRef<number | null>(null);
|
||||
@@ -328,22 +337,28 @@ export function useBlobbiEyes(
|
||||
blinkStateRef.current = updateBlinkState(blinkStateRef.current, timestamp);
|
||||
const blinkScaleY = calculateBlinkScale(blinkStateRef.current, timestamp);
|
||||
|
||||
// ─── Calculate Mouse Tracking ───────────────────────────────────────
|
||||
// Get Blobbi center position
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
// ─── Calculate Eye Position ───────────────────────────────────────
|
||||
let eyeX = 0;
|
||||
let eyeY = 0;
|
||||
|
||||
// Calculate direction to mouse
|
||||
const dx = globalMouseX - centerX;
|
||||
const dy = globalMouseY - centerY;
|
||||
if (lookMode === 'follow-pointer') {
|
||||
// Get Blobbi center position
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// Calculate angle to mouse
|
||||
const angle = Math.atan2(dy, dx);
|
||||
// Calculate direction to mouse
|
||||
const dx = globalMouseX - centerX;
|
||||
const dy = globalMouseY - centerY;
|
||||
|
||||
// Calculate eye position (instant, no interpolation)
|
||||
const eyeX = Math.cos(angle) * maxMovement;
|
||||
const eyeY = Math.sin(angle) * maxMovement * VERTICAL_SCALE;
|
||||
// Calculate angle to mouse
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
// Calculate eye position (instant, no interpolation)
|
||||
eyeX = Math.cos(angle) * maxMovement;
|
||||
eyeY = Math.sin(angle) * maxMovement * VERTICAL_SCALE;
|
||||
}
|
||||
// 'forward' mode: eyes stay at (0, 0) - looking straight ahead
|
||||
|
||||
// ─── Apply Tracking Transform (pupils only) ──────────────────────────
|
||||
// Only translate - no scale here. Eye whites stay fixed.
|
||||
@@ -390,5 +405,5 @@ export function useBlobbiEyes(
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isSleeping, maxMovement, containerRef]);
|
||||
}, [isSleeping, maxMovement, lookMode, containerRef]);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { BlobbiPhotoModal } from '@/blobbi/ui/BlobbiPhotoModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
@@ -760,6 +761,7 @@ function BlobbiDashboard({
|
||||
const [showShopModal, setShowShopModal] = useState(false);
|
||||
const [showInventoryModal, setShowInventoryModal] = useState(false);
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||
|
||||
// Adoption flow modal state
|
||||
const [showAdoptionFlow, setShowAdoptionFlow] = useState(false);
|
||||
@@ -1150,7 +1152,7 @@ function BlobbiDashboard({
|
||||
<BlobbiDashboardFloatingControls
|
||||
stage={companion.stage}
|
||||
onSetAsCompanion={() => console.log('TODO: set as companion')}
|
||||
onTakePhoto={() => console.log('TODO: take photo')}
|
||||
onTakePhoto={() => setShowPhotoModal(true)}
|
||||
onOpenPiP={() => console.log('TODO: open PiP')}
|
||||
onEvolve={
|
||||
// For eggs not yet incubating: show incubation dialog
|
||||
@@ -1441,6 +1443,13 @@ function BlobbiDashboard({
|
||||
onSuccess={refetchCurrentTasks}
|
||||
/>
|
||||
|
||||
{/* Blobbi Photo Modal - polaroid-style photo capture */}
|
||||
<BlobbiPhotoModal
|
||||
open={showPhotoModal}
|
||||
onOpenChange={setShowPhotoModal}
|
||||
companion={companion}
|
||||
/>
|
||||
|
||||
{/* Start Incubation Confirmation Dialog */}
|
||||
<StartIncubationDialog
|
||||
open={showIncubationDialog}
|
||||
|
||||
Reference in New Issue
Block a user