refactor(blobbi): extract pure SVG renderers and add explicit render mode
Architecture refactor for the Blobbi visual system:
1. Centralized debug helper (src/blobbi/ui/lib/debug.ts):
- Replaces all scattered console.log/trace instrumentation
- Single BLOBBI_DEBUG flag, only logs in DEV mode when enabled
- Typed debug categories for filtering
2. Explicit render mode API (BlobbiRenderMode: 'page' | 'companion'):
- Replaces implicit companion detection via eye offset prop sniffing
- Controls tracking, reaction class suppression, and future behaviors
- Default is 'page' — no changes needed for existing BlobbiPage callers
3. Pure SVG renderer extraction:
- BlobbiAdultSvgRenderer: resolve → customize → animate → recipe → sanitize → innerHTML
- BlobbiBabySvgRenderer: same pipeline for baby stage
- These components know nothing about hooks, modes, or runtime state
- Only rerender when visual content changes (blobbi, recipe, emotion, bodyEffects)
4. Visual wrappers simplified:
- BlobbiAdultVisual/BlobbiBabyVisual own the containerRef, eye hooks,
and reaction CSS classes — delegate SVG output to the renderers
- ~480 lines removed across the visual layer
Net result: -305 lines, zero debug console spam, clean separation between
SVG pipeline, eye behavior, and companion runtime.
This commit is contained in:
@@ -79,11 +79,6 @@ interface BlobbiCompanionProps {
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
// ─── DEBUG: Render frequency tracking ─────────────────────────────────────────
|
||||
const _companionRenderCount = { current: 0 };
|
||||
const _companionLastLogTime = { current: 0 };
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiCompanion({
|
||||
companion,
|
||||
state,
|
||||
@@ -110,16 +105,6 @@ export function BlobbiCompanion({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [animationTime, setAnimationTime] = useState(0);
|
||||
|
||||
// ─── DEBUG: Log render frequency (once per second summary) ─────────────
|
||||
_companionRenderCount.current++;
|
||||
const now = performance.now();
|
||||
if (now - _companionLastLogTime.current > 2000) {
|
||||
console.log(`[BlobbiCompanion] ${_companionRenderCount.current} renders in last 2s`);
|
||||
_companionRenderCount.current = 0;
|
||||
_companionLastLogTime.current = now;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Click detection - distinguishes click from drag
|
||||
const clickDetection = useClickDetection({
|
||||
onClick,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* - Outer shell: handles per-frame updates (float, shadow, drag state) — rerenders freely
|
||||
* - Inner MemoizedBlobbiVisual: renders the actual SVG — only rerenders when visual inputs change
|
||||
* - Eye gaze is driven imperatively via ref (no React rerenders for gaze)
|
||||
* - Reaction CSS classes (sway/bounce) applied on the outer float wrapper,
|
||||
* NOT on the SVG container, to keep the SVG DOM node stable.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, memo, type RefObject } from 'react';
|
||||
@@ -18,56 +20,35 @@ import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
// BlobbiReactionState not needed — reaction classes applied on outer wrapper, not passed to memoized visual
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiCompanionVisualProps {
|
||||
/** Companion data */
|
||||
companion: CompanionData;
|
||||
/** Size in pixels */
|
||||
size: number;
|
||||
/** Ref-based eye offset for imperative gaze control (no rerenders) */
|
||||
eyeOffsetRef: RefObject<EyeOffset>;
|
||||
/** Facing direction (used for gaze, not for flipping) */
|
||||
direction: CompanionDirection;
|
||||
/** Whether the companion is being dragged */
|
||||
isDragging: boolean;
|
||||
/** Whether the companion is walking */
|
||||
isWalking: boolean;
|
||||
/** Floating animation offset for gentle sway */
|
||||
floatOffset?: { x: number; y: number; rotation: number };
|
||||
/** Whether Blobbi is on or near the ground (affects shadow visibility) */
|
||||
isOnGround?: boolean;
|
||||
/** Distance from ground in pixels (for shadow fade, 0 = on ground) */
|
||||
distanceFromGround?: number;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset (convenience). Ignored when `recipe` is provided. */
|
||||
emotion?: BlobbiEmotion;
|
||||
/**
|
||||
* Body-level visual effects — for manual/external use only.
|
||||
* Status-reaction body effects are already folded into the recipe.
|
||||
*/
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
/** Debug mode - shows visual boundaries */
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
// ─── Memoized Inner Visual ────────────────────────────────────────────────────
|
||||
//
|
||||
// This component renders the actual Blobbi SVG (BlobbiAdultVisual / BlobbiBabyVisual).
|
||||
// It is wrapped in React.memo with a custom comparator so it only rerenders when
|
||||
// the actual visual inputs change — NOT when per-frame props like floatOffset,
|
||||
// isDragging, isWalking, or eyeOffset change.
|
||||
//
|
||||
// Eye gaze is controlled imperatively: the ref is passed to useExternalEyeOffset
|
||||
// inside the visual components, which reads from it in its own RAF loop.
|
||||
// Renders BlobbiAdultVisual / BlobbiBabyVisual with renderMode="companion".
|
||||
// Wrapped in React.memo so it only rerenders when visual content changes.
|
||||
// Per-frame props (float, drag, walking, gaze) are excluded.
|
||||
|
||||
interface MemoizedBlobbiVisualProps {
|
||||
stage: 'baby' | 'adult';
|
||||
@@ -79,13 +60,6 @@ interface MemoizedBlobbiVisualProps {
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized inner visual — renders the actual SVG.
|
||||
*
|
||||
* Does NOT receive reaction/walking/dragging props.
|
||||
* CSS sway/bounce classes are applied on an outer wrapper in BlobbiCompanionVisual.
|
||||
* This component only rerenders when visual content (recipe, emotion, blobbi data) changes.
|
||||
*/
|
||||
const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
|
||||
stage,
|
||||
blobbi,
|
||||
@@ -99,6 +73,7 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
|
||||
return (
|
||||
<BlobbiBabyVisual
|
||||
blobbi={blobbi}
|
||||
renderMode="companion"
|
||||
lookMode="forward"
|
||||
externalEyeOffsetRef={eyeOffsetRef}
|
||||
recipe={recipe}
|
||||
@@ -113,6 +88,7 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
|
||||
return (
|
||||
<BlobbiAdultVisual
|
||||
blobbi={blobbi}
|
||||
renderMode="companion"
|
||||
lookMode="forward"
|
||||
externalEyeOffsetRef={eyeOffsetRef}
|
||||
recipe={recipe}
|
||||
@@ -123,7 +99,6 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Custom comparator: only rerender when visual inputs change
|
||||
return (
|
||||
prev.stage === next.stage &&
|
||||
prev.blobbi === next.blobbi &&
|
||||
@@ -131,15 +106,10 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
|
||||
prev.recipeLabel === next.recipeLabel &&
|
||||
prev.emotion === next.emotion &&
|
||||
prev.bodyEffects === next.bodyEffects
|
||||
// eyeOffsetRef is a stable ref — never changes identity
|
||||
);
|
||||
});
|
||||
|
||||
// ─── DEBUG: Companion prop stability ──────────────────────────────────────────
|
||||
const _visualRenderCount = { current: 0 };
|
||||
const _visualPrevCompanionRef = { current: null as CompanionData | null };
|
||||
const _visualPrevBlobbiRef = { current: null as ReturnType<typeof companionDataToBlobbi> | null };
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiCompanionVisual({
|
||||
companion,
|
||||
@@ -159,137 +129,69 @@ export function BlobbiCompanionVisual({
|
||||
debugMode = false,
|
||||
}: BlobbiCompanionVisualProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]);
|
||||
|
||||
// ─── DEBUG: Track companion → blobbi object stability ─────────────────
|
||||
_visualRenderCount.current++;
|
||||
if (_visualRenderCount.current <= 5 || _visualRenderCount.current % 100 === 0) {
|
||||
console.log(`[CompanionVisual] render #${_visualRenderCount.current}`);
|
||||
}
|
||||
if (companion !== _visualPrevCompanionRef.current) {
|
||||
console.log(`%c[CompanionVisual] companion REFERENCE changed (render #${_visualRenderCount.current})`, 'color: #8b5cf6; font-weight: bold');
|
||||
_visualPrevCompanionRef.current = companion;
|
||||
}
|
||||
if (blobbi !== _visualPrevBlobbiRef.current) {
|
||||
console.log(`%c[CompanionVisual] blobbi REFERENCE changed (render #${_visualRenderCount.current}) — this triggers SVG rebuild`, 'color: #ef4444; font-weight: bold');
|
||||
_visualPrevBlobbiRef.current = blobbi;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// DEV ONLY: Get effective emotion from dev context (overrides production emotions)
|
||||
const devEmotion = useEffectiveEmotion();
|
||||
const hasDevOverride = devEmotion !== 'neutral';
|
||||
|
||||
// Final rendering: dev override > props from status reaction system
|
||||
|
||||
const effectiveRecipe = hasDevOverride ? undefined : recipeProp;
|
||||
const effectiveRecipeLabel = hasDevOverride ? undefined : recipeLabelProp;
|
||||
const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral');
|
||||
const effectiveBodyEffects = hasDevOverride ? undefined : bodyEffectsProp;
|
||||
|
||||
// Build transform for floating animation
|
||||
// No flipping based on direction - Blobbi always faces the same way
|
||||
|
||||
// Float transform
|
||||
const blobbiTransform = useMemo(() => {
|
||||
const transforms: string[] = [];
|
||||
|
||||
if (floatOffset.x !== 0 || floatOffset.y !== 0) {
|
||||
transforms.push(`translate(${floatOffset.x}px, ${floatOffset.y}px)`);
|
||||
}
|
||||
if (floatOffset.rotation !== 0) {
|
||||
transforms.push(`rotate(${floatOffset.rotation}deg)`);
|
||||
}
|
||||
|
||||
return transforms.length > 0 ? transforms.join(' ') : undefined;
|
||||
}, [floatOffset]);
|
||||
|
||||
// Determine reaction state for CSS animations
|
||||
// - happy: when being dragged (Blobbi enjoys interaction)
|
||||
// - swaying: when walking (natural movement animation)
|
||||
// - idle: default state
|
||||
|
||||
// Reaction state for CSS animations on the OUTER wrapper
|
||||
const reaction = isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
|
||||
|
||||
// Shadow visibility and appearance based on ground proximity
|
||||
// Shadow should only appear when Blobbi is on or very near the ground
|
||||
const SHADOW_FADE_DISTANCE = 30; // Shadow fully fades at this distance from ground
|
||||
|
||||
// ── Shadow ─────────────────────────────────────────────────────────────────
|
||||
const SHADOW_FADE_DISTANCE = 30;
|
||||
const SHADOW_MAX_OPACITY = 0.35;
|
||||
|
||||
// Calculate shadow visibility based on actual ground distance, not just float offset
|
||||
|
||||
const showShadow = isOnGround && !isDragging && distanceFromGround < SHADOW_FADE_DISTANCE;
|
||||
|
||||
// Shadow fades as Blobbi gets farther from ground
|
||||
// Also factor in the float animation offset for subtle breathing effect
|
||||
const floatHeight = Math.abs(floatOffset.y);
|
||||
const groundFadeRatio = Math.max(0, 1 - distanceFromGround / SHADOW_FADE_DISTANCE);
|
||||
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02); // Subtle fade during float
|
||||
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02);
|
||||
const shadowOpacity = SHADOW_MAX_OPACITY * groundFadeRatio * floatFadeRatio;
|
||||
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio; // Slightly smaller when lifting
|
||||
|
||||
// Suppress unused variable warning for direction (kept for API compatibility)
|
||||
void direction;
|
||||
|
||||
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio;
|
||||
|
||||
void direction; // kept for API compatibility
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative', className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* DEBUG: Container and alignment markers */}
|
||||
{/* Debug alignment markers */}
|
||||
{debugMode && (
|
||||
<>
|
||||
{/* Container outline - lime */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
border: '2px solid lime',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{/* 88% line from top (where SVG body bottom should be before shift) - yellow */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: `${size * 0.88}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
backgroundColor: 'yellow',
|
||||
}}
|
||||
/>
|
||||
{/* 100% line (container bottom where body should touch after shift) - cyan */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
backgroundColor: 'cyan',
|
||||
}}
|
||||
/>
|
||||
{/* Label showing the expected shift */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: 2,
|
||||
left: 2,
|
||||
fontSize: 8,
|
||||
color: 'white',
|
||||
backgroundColor: 'black',
|
||||
padding: '1px 2px',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ border: '2px solid lime', boxSizing: 'border-box' }} />
|
||||
<div className="absolute pointer-events-none" style={{ top: `${size * 0.88}px`, left: 0, right: 0, height: 2, backgroundColor: 'yellow' }} />
|
||||
<div className="absolute pointer-events-none" style={{ bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'cyan' }} />
|
||||
<div className="absolute pointer-events-none" style={{ top: 2, left: 2, fontSize: 8, color: 'white', backgroundColor: 'black', padding: '1px 2px' }}>
|
||||
shift: {size * 0.12}px
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floor shadow - only visible when Blobbi is on/near the ground */}
|
||||
{/* Hidden during: dragging, entry animations, falling, or when far from ground */}
|
||||
|
||||
{/* Floor shadow */}
|
||||
{!debugMode && showShadow && shadowOpacity > 0.01 && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
// Position shadow well below Blobbi to feel like it's on the floor
|
||||
bottom: -20,
|
||||
left: '50%',
|
||||
width: size * 0.5,
|
||||
@@ -298,40 +200,28 @@ export function BlobbiCompanionVisual({
|
||||
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowOpacity}) 0%, rgba(0,0,0,${shadowOpacity * 0.5}) 40%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(4px)',
|
||||
opacity: groundFadeRatio, // Additional opacity control for smooth fade
|
||||
opacity: groundFadeRatio,
|
||||
transition: 'opacity 0.15s ease-out, transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Blobbi visual with floating transform */}
|
||||
{/*
|
||||
The Blobbi SVG has empty space: 15% at top (body starts at y=15), 12% at bottom (body ends at y=88).
|
||||
To align the visible body bottom with the container bottom, we shift down by 12% of container size.
|
||||
This is applied BEFORE the float transform so the ground position is correct.
|
||||
*/}
|
||||
|
||||
{/* Float + reaction wrapper */}
|
||||
<div
|
||||
className={cn(
|
||||
'size-full',
|
||||
// Reaction CSS animations applied HERE (outer wrapper), not on the SVG container.
|
||||
// This prevents className changes from triggering dangerouslySetInnerHTML replacement.
|
||||
// Companion reactions: 'swaying' (walking), 'happy' (dragging), 'idle' (default)
|
||||
(reaction === 'swaying' || reaction === 'happy') && 'animate-blobbi-sway',
|
||||
)}
|
||||
style={{
|
||||
// First apply the SVG alignment correction, then the float animation
|
||||
// The 12% shift pushes the SVG down so its visible body bottom aligns with container bottom
|
||||
transform: [
|
||||
`translateY(${size * 0.12}px)`, // SVG body alignment correction
|
||||
blobbiTransform, // Float animation (if any)
|
||||
`translateY(${size * 0.12}px)`,
|
||||
blobbiTransform,
|
||||
].filter(Boolean).join(' ') || undefined,
|
||||
transformOrigin: 'center bottom',
|
||||
transition: isDragging ? 'none' : 'transform 0.05s ease-out',
|
||||
// DEBUG: Show the shifted wrapper
|
||||
...(debugMode ? { outline: '2px dashed magenta' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Memoized visual: only rerenders when visual inputs change */}
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* BlobbiAdultSvgRenderer — Pure SVG rendering component for adult Blobbi.
|
||||
*
|
||||
* This component is the leaf node of the visual pipeline. It:
|
||||
* 1. Resolves the base SVG for the adult form
|
||||
* 2. Customizes colors and unique IDs
|
||||
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
|
||||
* 4. Applies visual recipe or emotion preset
|
||||
* 5. Applies manual body effects (when no recipe is provided)
|
||||
* 6. Sanitizes the SVG
|
||||
* 7. Renders via dangerouslySetInnerHTML
|
||||
*
|
||||
* It does NOT know about:
|
||||
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
|
||||
* - Render mode (page vs companion)
|
||||
* - Reaction CSS classes (sway / bounce)
|
||||
* - Companion runtime (drag, float, position)
|
||||
*
|
||||
* This separation ensures that the SVG DOM node stays mounted and stable
|
||||
* as long as the visual inputs don't change. SMIL and CSS animations
|
||||
* inside the SVG continue running across parent rerenders.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
|
||||
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import { debugBlobbi } from './lib/debug';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
export interface BlobbiAdultSvgRendererProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Whether the Blobbi is sleeping */
|
||||
isSleeping: boolean;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (used in CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Body-level visual effects (manual/external use only — not from status reaction). */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure SVG renderer for adult Blobbi.
|
||||
*
|
||||
* The containerRef is forwarded via the returned div so that parent
|
||||
* components can attach eye-tracking hooks to query the SVG DOM.
|
||||
*/
|
||||
export function BlobbiAdultSvgRenderer({
|
||||
blobbi,
|
||||
isSleeping,
|
||||
recipe: recipeProp,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiAdultSvgRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const customizedSvg = useMemo(() => {
|
||||
debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild');
|
||||
|
||||
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping });
|
||||
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping);
|
||||
|
||||
if (!isSleeping) {
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
if (recipeProp) {
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id);
|
||||
} else if (emotion !== 'neutral') {
|
||||
const resolved = resolveVisualRecipe(emotion);
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id);
|
||||
}
|
||||
|
||||
if (bodyEffects && !recipeProp) {
|
||||
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}
|
||||
|
||||
return colorizedSvg;
|
||||
}, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]);
|
||||
|
||||
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +1,92 @@
|
||||
/**
|
||||
* BlobbiAdultVisual - Reusable component for rendering Blobbi adults
|
||||
* BlobbiAdultVisual — Visual wrapper for rendering Blobbi adults.
|
||||
*
|
||||
* Uses the adult-blobbi module for SVG resolution and customization.
|
||||
* Handles awake vs sleeping states automatically.
|
||||
* Supports multiple adult evolution forms.
|
||||
* Eyes always track the mouse cursor in real-time.
|
||||
* Responsibilities:
|
||||
* - Owns the container ref for eye hooks to query SVG DOM
|
||||
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
|
||||
* - Runs useExternalEyeOffset (companion gaze RAF loop)
|
||||
* - Applies reaction CSS classes (sway/bounce) in page mode
|
||||
* - Delegates SVG rendering to BlobbiAdultSvgRenderer
|
||||
*
|
||||
* Accepts either:
|
||||
* - `recipe` + `recipeLabel`: a pre-resolved visual recipe (recipe-first path
|
||||
* from useStatusReaction). The recipe includes body effects — no separate
|
||||
* bodyEffects prop is needed for this path.
|
||||
* - `emotion`: a named emotion preset (convenience path, resolved internally)
|
||||
* The SVG renderer is a separate component so the dangerouslySetInnerHTML
|
||||
* node stays mounted even when wrapper-level props change (reaction,
|
||||
* className toggles, etc.).
|
||||
*
|
||||
* An optional `bodyEffects` prop is available for manual/external use cases
|
||||
* outside the status reaction system (e.g. dev tools, previews). It is NOT
|
||||
* fed from useStatusReaction to avoid double-applying body effects.
|
||||
* Render modes:
|
||||
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
|
||||
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
|
||||
* suppressed (applied by outer companion wrapper instead).
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useEffect, type RefObject } from 'react';
|
||||
import { useRef, type RefObject } from 'react';
|
||||
|
||||
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
|
||||
import type { ExternalEyeOffset, BlobbiReactionState } from './lib/types';
|
||||
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
|
||||
import type { BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import type { BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
|
||||
import { BlobbiAdultSvgRenderer } from './BlobbiAdultSvgRenderer';
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export type { ExternalEyeOffset };
|
||||
|
||||
/**
|
||||
* Reaction states for adult Blobbi animations
|
||||
* @deprecated Use BlobbiReactionState from './lib/types' instead
|
||||
*/
|
||||
/** @deprecated Use BlobbiReactionState from './lib/types' instead */
|
||||
export type AdultReactionState = BlobbiReactionState;
|
||||
|
||||
export interface BlobbiAdultVisualProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: AdultReactionState;
|
||||
reaction?: BlobbiReactionState;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Disable blinking animation (for photo/export mode) */
|
||||
disableBlink?: boolean;
|
||||
/**
|
||||
* External eye offset from companion system (value-based — causes rerenders).
|
||||
* When provided, bypasses internal mouse tracking and uses this offset directly.
|
||||
*/
|
||||
/** External eye offset (value-based — causes rerenders). */
|
||||
externalEyeOffset?: ExternalEyeOffset;
|
||||
/**
|
||||
* Ref-based external eye offset (imperative — no rerenders).
|
||||
* Preferred for companion mode. When provided, takes precedence over externalEyeOffset.
|
||||
*/
|
||||
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
|
||||
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
|
||||
/**
|
||||
* Pre-resolved visual recipe. When provided, takes precedence over `emotion`.
|
||||
* This is the recipe-first rendering path used by useStatusReaction.
|
||||
*/
|
||||
/** Render mode. Default: 'page'. */
|
||||
renderMode?: BlobbiRenderMode;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/**
|
||||
* Label for the recipe (used in CSS class names). Required when `recipe` is provided.
|
||||
*/
|
||||
/** Label for the recipe (used in CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/**
|
||||
* Named emotion preset (convenience path).
|
||||
* Ignored when `recipe` is provided.
|
||||
* Default: 'neutral' (no modifications)
|
||||
*/
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
|
||||
emotion?: BlobbiEmotion;
|
||||
/**
|
||||
* Body-level visual effects (dirt marks, stink clouds, etc.).
|
||||
* Optional — for manual/external use cases only.
|
||||
* Do NOT pass status-reaction body effects here; those are already
|
||||
* folded into the recipe and applied by applyVisualRecipe().
|
||||
*/
|
||||
/** Body-level visual effects (manual/external use only). */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── DEBUG: Animation lifecycle instrumentation ──────────────────────────────
|
||||
const _adultSvgRebuildCount = { current: 0 };
|
||||
const _adultSafeSvgCount = { current: 0 };
|
||||
const _adultRenderCount = { current: 0 };
|
||||
const _adultPrevProps = { current: null as Record<string, unknown> | null };
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, externalEyeOffsetRef, recipe: recipeProp, recipeLabel, emotion = 'neutral', bodyEffects, className }: BlobbiAdultVisualProps) {
|
||||
export function BlobbiAdultVisual({
|
||||
blobbi,
|
||||
reaction = 'idle',
|
||||
lookMode = 'follow-pointer',
|
||||
disableBlink = false,
|
||||
externalEyeOffset,
|
||||
externalEyeOffsetRef,
|
||||
renderMode = 'page',
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiAdultVisualProps) {
|
||||
const isSleeping = isBlobbiSleeping(blobbi);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isCompanion = renderMode === 'companion';
|
||||
|
||||
// ─── DEBUG: Track renders and prop changes ───────────────────────────────
|
||||
_adultRenderCount.current++;
|
||||
const isCompanion = !!(externalEyeOffset || externalEyeOffsetRef);
|
||||
if (isCompanion) {
|
||||
const currentProps: Record<string, unknown> = {
|
||||
blobbiId: blobbi.id,
|
||||
blobbiRef: blobbi,
|
||||
isSleeping,
|
||||
recipeProp,
|
||||
recipeLabel,
|
||||
emotion,
|
||||
bodyEffects,
|
||||
};
|
||||
const prev = _adultPrevProps.current;
|
||||
if (prev) {
|
||||
const changed: string[] = [];
|
||||
for (const key of Object.keys(currentProps)) {
|
||||
if (currentProps[key] !== prev[key]) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
console.log(`%c[AdultVisual] COMPANION render #${_adultRenderCount.current} — props changed: ${changed.join(', ')}`, 'color: #f59e0b; font-weight: bold');
|
||||
for (const key of changed) {
|
||||
console.log(` ${key}: `, prev[key], ' → ', currentProps[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_adultPrevProps.current = currentProps;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Reaction controls CSS sway/bounce classes. Suppressed when sleeping.
|
||||
// NOTE: In companion mode, these classes are applied on an OUTER wrapper
|
||||
// (BlobbiCompanionVisual) rather than on this component, so this component
|
||||
// can remain a pure SVG renderer that doesn't rerender for walking changes.
|
||||
const effectiveReaction = isSleeping ? 'idle' : reaction;
|
||||
|
||||
// ── Eye hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
useBlobbiEyes(containerRef, {
|
||||
isSleeping,
|
||||
maxMovement: 2.5,
|
||||
@@ -154,77 +103,9 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo
|
||||
variant: 'adult',
|
||||
});
|
||||
|
||||
const customizedSvg = useMemo(() => {
|
||||
// ─── DEBUG: Track SVG rebuilds ──────────────────────────────────────
|
||||
if (isCompanion) {
|
||||
_adultSvgRebuildCount.current++;
|
||||
console.log(`%c[AdultVisual] COMPANION customizedSvg rebuild #${_adultSvgRebuildCount.current}`, 'color: #ef4444; font-weight: bold');
|
||||
console.trace('[AdultVisual] SVG rebuild stack trace');
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping });
|
||||
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping);
|
||||
|
||||
if (!isSleeping) {
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
// Recipe-first path: use pre-resolved recipe if provided.
|
||||
// applyVisualRecipe() handles everything including body effects
|
||||
// embedded in the recipe, so no separate applyBodyEffects() needed.
|
||||
if (recipeProp) {
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id);
|
||||
} else if (emotion !== 'neutral') {
|
||||
// Convenience path: resolve named emotion preset
|
||||
const resolved = resolveVisualRecipe(emotion);
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id);
|
||||
}
|
||||
|
||||
// Manual body effects prop — only applied when no recipe was provided,
|
||||
// since applyVisualRecipe() already applies recipe.bodyEffects.
|
||||
if (bodyEffects && !recipeProp) {
|
||||
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}
|
||||
|
||||
return colorizedSvg;
|
||||
}, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]);
|
||||
|
||||
const safeSvg = useMemo(() => {
|
||||
// ─── DEBUG: Track sanitization rebuilds ──────────────────────────────
|
||||
if (isCompanion) {
|
||||
_adultSafeSvgCount.current++;
|
||||
console.log(`%c[AdultVisual] COMPANION safeSvg rebuild #${_adultSafeSvgCount.current}`, 'color: #ef4444');
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
return sanitizeBlobbiSvg(customizedSvg);
|
||||
}, [customizedSvg]);
|
||||
|
||||
// ─── DEBUG: Track DOM node identity (detect remounts vs rerenders) ──────
|
||||
const prevSvgNodeRef = useRef<Element | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isCompanion || !containerRef.current) return;
|
||||
const svgNode = containerRef.current.querySelector('svg');
|
||||
if (svgNode && svgNode !== prevSvgNodeRef.current) {
|
||||
if (prevSvgNodeRef.current === null) {
|
||||
console.log('%c[AdultVisual] COMPANION: SVG node mounted (first time)', 'color: #22c55e');
|
||||
} else {
|
||||
console.log('%c[AdultVisual] COMPANION: SVG DOM NODE REPLACED! (animations killed)', 'color: #ef4444; font-weight: bold; font-size: 14px');
|
||||
}
|
||||
prevSvgNodeRef.current = svgNode;
|
||||
// Check for SMIL animations
|
||||
const animates = svgNode.querySelectorAll('animate, animateTransform');
|
||||
console.log(` SVG has ${animates.length} SMIL animation elements`);
|
||||
}
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// In companion mode, reaction CSS classes are applied by the outer wrapper
|
||||
// (BlobbiCompanionVisual) so this div stays className-stable and
|
||||
// dangerouslySetInnerHTML doesn't cause the browser to replace the SVG node.
|
||||
const applyReactionClasses = !isCompanion;
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
// In companion mode, reaction classes are applied by an outer wrapper to
|
||||
// keep the dangerouslySetInnerHTML div className-stable.
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -232,14 +113,23 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo
|
||||
className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
isSleeping && 'opacity-70',
|
||||
applyReactionClasses && (effectiveReaction === 'listening' ||
|
||||
!isCompanion && (effectiveReaction === 'listening' ||
|
||||
effectiveReaction === 'swaying' ||
|
||||
effectiveReaction === 'happy') &&
|
||||
'animate-blobbi-sway',
|
||||
applyReactionClasses && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className
|
||||
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
>
|
||||
<BlobbiAdultSvgRenderer
|
||||
blobbi={blobbi}
|
||||
isSleeping={isSleeping}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* BlobbiBabySvgRenderer — Pure SVG rendering component for baby Blobbi.
|
||||
*
|
||||
* This component is the leaf node of the visual pipeline. It:
|
||||
* 1. Resolves the base SVG for the baby
|
||||
* 2. Customizes colors and unique IDs
|
||||
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
|
||||
* 4. Applies visual recipe or emotion preset
|
||||
* 5. Applies manual body effects (when no recipe is provided)
|
||||
* 6. Sanitizes the SVG
|
||||
* 7. Renders via dangerouslySetInnerHTML
|
||||
*
|
||||
* It does NOT know about:
|
||||
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
|
||||
* - Render mode (page vs companion)
|
||||
* - Reaction CSS classes (sway / bounce)
|
||||
* - Companion runtime (drag, float, position)
|
||||
*/
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
|
||||
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import { debugBlobbi } from './lib/debug';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
export interface BlobbiBabySvgRendererProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Whether the Blobbi is sleeping */
|
||||
isSleeping: boolean;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (used in CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Body-level visual effects (manual/external use only — not from status reaction). */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure SVG renderer for baby Blobbi.
|
||||
*/
|
||||
export function BlobbiBabySvgRenderer({
|
||||
blobbi,
|
||||
isSleeping,
|
||||
recipe: recipeProp,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiBabySvgRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const customizedSvg = useMemo(() => {
|
||||
debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild');
|
||||
|
||||
const baseSvg = resolveBabySvg(blobbi, { isSleeping });
|
||||
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping);
|
||||
|
||||
if (!isSleeping) {
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
if (recipeProp) {
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id);
|
||||
} else if (emotion !== 'neutral') {
|
||||
const resolved = resolveVisualRecipe(emotion);
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id);
|
||||
}
|
||||
|
||||
if (bodyEffects && !recipeProp) {
|
||||
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}
|
||||
|
||||
return colorizedSvg;
|
||||
}, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]);
|
||||
|
||||
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +1,82 @@
|
||||
/**
|
||||
* BlobbiBabyVisual - Reusable component for rendering Blobbi babies
|
||||
* BlobbiBabyVisual — Visual wrapper for rendering Blobbi babies.
|
||||
*
|
||||
* Uses the baby-blobbi module for SVG resolution and customization.
|
||||
* Handles awake vs sleeping states automatically.
|
||||
* Eyes always track the mouse cursor in real-time.
|
||||
* Responsibilities:
|
||||
* - Owns the container ref for eye hooks to query SVG DOM
|
||||
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
|
||||
* - Runs useExternalEyeOffset (companion gaze RAF loop)
|
||||
* - Applies reaction CSS classes (sway/bounce) in page mode
|
||||
* - Delegates SVG rendering to BlobbiBabySvgRenderer
|
||||
*
|
||||
* Accepts either:
|
||||
* - `recipe` + `recipeLabel`: a pre-resolved visual recipe (recipe-first path
|
||||
* from useStatusReaction). The recipe includes body effects — no separate
|
||||
* bodyEffects prop is needed for this path.
|
||||
* - `emotion`: a named emotion preset (convenience path, resolved internally)
|
||||
*
|
||||
* An optional `bodyEffects` prop is available for manual/external use cases
|
||||
* outside the status reaction system.
|
||||
* Render modes:
|
||||
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
|
||||
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
|
||||
* suppressed (applied by outer companion wrapper instead).
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useEffect, type RefObject } from 'react';
|
||||
import { useRef, type RefObject } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
|
||||
import type { ExternalEyeOffset, BlobbiReactionState } from './lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
|
||||
import type { BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import type { BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
|
||||
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
import { BlobbiBabySvgRenderer } from './BlobbiBabySvgRenderer';
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export type { ExternalEyeOffset };
|
||||
|
||||
/**
|
||||
* @deprecated Use BlobbiReactionState from './lib/types' instead
|
||||
*/
|
||||
/** @deprecated Use BlobbiReactionState from './lib/types' instead */
|
||||
export type BabyReactionState = BlobbiReactionState;
|
||||
|
||||
export interface BlobbiBabyVisualProps {
|
||||
blobbi: Blobbi;
|
||||
reaction?: BabyReactionState;
|
||||
reaction?: BlobbiReactionState;
|
||||
lookMode?: BlobbiLookMode;
|
||||
disableBlink?: boolean;
|
||||
externalEyeOffset?: ExternalEyeOffset;
|
||||
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
|
||||
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
|
||||
/** Render mode. Default: 'page'. */
|
||||
renderMode?: BlobbiRenderMode;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (CSS class names). Required when `recipe` is provided. */
|
||||
/** Label for the recipe (CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset (convenience path). Ignored when `recipe` is provided. */
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. */
|
||||
emotion?: BlobbiEmotion;
|
||||
/**
|
||||
* Body-level visual effects — for manual/external use only.
|
||||
* Status-reaction body effects are already in the recipe.
|
||||
*/
|
||||
/** Body-level visual effects — for manual/external use only. */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── DEBUG: Animation lifecycle instrumentation ──────────────────────────────
|
||||
const _babySvgRebuildCount = { current: 0 };
|
||||
const _babySafeSvgCount = { current: 0 };
|
||||
const _babyRenderCount = { current: 0 };
|
||||
const _babyPrevProps = { current: null as Record<string, unknown> | null };
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, externalEyeOffsetRef, recipe: recipeProp, recipeLabel, emotion = 'neutral', bodyEffects, className }: BlobbiBabyVisualProps) {
|
||||
export function BlobbiBabyVisual({
|
||||
blobbi,
|
||||
reaction = 'idle',
|
||||
lookMode = 'follow-pointer',
|
||||
disableBlink = false,
|
||||
externalEyeOffset,
|
||||
externalEyeOffsetRef,
|
||||
renderMode = 'page',
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiBabyVisualProps) {
|
||||
const isSleeping = isBlobbiSleeping(blobbi);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ─── DEBUG: Track renders and prop changes ───────────────────────────────
|
||||
_babyRenderCount.current++;
|
||||
const isCompanion = !!(externalEyeOffset || externalEyeOffsetRef);
|
||||
if (isCompanion) {
|
||||
const currentProps: Record<string, unknown> = {
|
||||
blobbiId: blobbi.id,
|
||||
blobbiRef: blobbi,
|
||||
isSleeping,
|
||||
recipeProp,
|
||||
recipeLabel,
|
||||
emotion,
|
||||
bodyEffects,
|
||||
};
|
||||
const prev = _babyPrevProps.current;
|
||||
if (prev) {
|
||||
const changed: string[] = [];
|
||||
for (const key of Object.keys(currentProps)) {
|
||||
if (currentProps[key] !== prev[key]) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
console.log(`%c[BabyVisual] COMPANION render #${_babyRenderCount.current} — props changed: ${changed.join(', ')}`, 'color: #f59e0b; font-weight: bold');
|
||||
for (const key of changed) {
|
||||
console.log(` ${key}: `, prev[key], ' → ', currentProps[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_babyPrevProps.current = currentProps;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const isCompanion = renderMode === 'companion';
|
||||
|
||||
const effectiveReaction = isSleeping ? 'idle' : reaction;
|
||||
|
||||
// ── Eye hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
useBlobbiEyes(containerRef, {
|
||||
isSleeping,
|
||||
maxMovement: 2,
|
||||
@@ -123,71 +93,7 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow
|
||||
variant: 'baby',
|
||||
});
|
||||
|
||||
const customizedSvg = useMemo(() => {
|
||||
// ─── DEBUG: Track SVG rebuilds ──────────────────────────────────────
|
||||
if (isCompanion) {
|
||||
_babySvgRebuildCount.current++;
|
||||
console.log(`%c[BabyVisual] COMPANION customizedSvg rebuild #${_babySvgRebuildCount.current}`, 'color: #ef4444; font-weight: bold');
|
||||
console.trace('[BabyVisual] SVG rebuild stack trace');
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseSvg = resolveBabySvg(blobbi, { isSleeping });
|
||||
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping);
|
||||
|
||||
if (!isSleeping) {
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
// Recipe-first path: applyVisualRecipe() handles body effects in the recipe.
|
||||
if (recipeProp) {
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id);
|
||||
} else if (emotion !== 'neutral') {
|
||||
const resolved = resolveVisualRecipe(emotion);
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id);
|
||||
}
|
||||
|
||||
// Manual body effects prop — only when no recipe was provided.
|
||||
if (bodyEffects && !recipeProp) {
|
||||
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}
|
||||
|
||||
return colorizedSvg;
|
||||
}, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]);
|
||||
|
||||
const safeSvg = useMemo(() => {
|
||||
// ─── DEBUG: Track sanitization rebuilds ──────────────────────────────
|
||||
if (isCompanion) {
|
||||
_babySafeSvgCount.current++;
|
||||
console.log(`%c[BabyVisual] COMPANION safeSvg rebuild #${_babySafeSvgCount.current}`, 'color: #ef4444');
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
return sanitizeBlobbiSvg(customizedSvg);
|
||||
}, [customizedSvg]);
|
||||
|
||||
// ─── DEBUG: Track DOM node identity (detect remounts vs rerenders) ──────
|
||||
const prevSvgNodeRef = useRef<Element | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isCompanion || !containerRef.current) return;
|
||||
const svgNode = containerRef.current.querySelector('svg');
|
||||
if (svgNode && svgNode !== prevSvgNodeRef.current) {
|
||||
if (prevSvgNodeRef.current === null) {
|
||||
console.log('%c[BabyVisual] COMPANION: SVG node mounted (first time)', 'color: #22c55e');
|
||||
} else {
|
||||
console.log('%c[BabyVisual] COMPANION: SVG DOM NODE REPLACED! (animations killed)', 'color: #ef4444; font-weight: bold; font-size: 14px');
|
||||
}
|
||||
prevSvgNodeRef.current = svgNode;
|
||||
const animates = svgNode.querySelectorAll('animate, animateTransform');
|
||||
console.log(` SVG has ${animates.length} SMIL animation elements`);
|
||||
}
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// In companion mode, reaction CSS classes are applied by the outer wrapper
|
||||
// (BlobbiCompanionVisual) so this div stays className-stable.
|
||||
const applyReactionClasses = !isCompanion;
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -195,14 +101,23 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow
|
||||
className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
isSleeping && 'opacity-70',
|
||||
applyReactionClasses && (effectiveReaction === 'listening' ||
|
||||
!isCompanion && (effectiveReaction === 'listening' ||
|
||||
effectiveReaction === 'swaying' ||
|
||||
effectiveReaction === 'happy') &&
|
||||
'animate-blobbi-sway',
|
||||
applyReactionClasses && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className
|
||||
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
>
|
||||
<BlobbiBabySvgRenderer
|
||||
blobbi={blobbi}
|
||||
isSleeping={isSleeping}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Centralized debug logging for Blobbi visual system.
|
||||
*
|
||||
* All Blobbi debug logging should go through this helper.
|
||||
* Only logs when BOTH conditions are met:
|
||||
* 1. Running in development mode (import.meta.env.DEV)
|
||||
* 2. BLOBBI_DEBUG flag is enabled
|
||||
*
|
||||
* To enable: set BLOBBI_DEBUG = true below.
|
||||
* To disable: set BLOBBI_DEBUG = false (default for production-clean console).
|
||||
*/
|
||||
|
||||
/** Master switch for Blobbi visual debug logging. */
|
||||
const BLOBBI_DEBUG = false;
|
||||
|
||||
type DebugCategory =
|
||||
| 'svg-rebuild' // SVG pipeline rebuilds (customizedSvg / safeSvg)
|
||||
| 'dom-replace' // SVG DOM node was replaced (animation killer)
|
||||
| 'dom-mount' // SVG DOM node mounted for first time
|
||||
| 'prop-change' // Props changed on a visual component
|
||||
| 'ref-change' // Object reference changed (companion, blobbi, recipe)
|
||||
| 'render-freq' // Render frequency tracking
|
||||
| 'smil' // SMIL animation element counts
|
||||
| 'recipe' // Recipe resolution and stability
|
||||
| 'general'; // Catch-all
|
||||
|
||||
/**
|
||||
* Log a Blobbi debug message.
|
||||
*
|
||||
* @param category - Debug category for filtering
|
||||
* @param args - Arguments forwarded to console.log
|
||||
*/
|
||||
export function debugBlobbi(category: DebugCategory, ...args: unknown[]): void {
|
||||
if (!import.meta.env.DEV || !BLOBBI_DEBUG) return;
|
||||
console.log(`[blobbi:${category}]`, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a Blobbi debug warning (always styled as warning).
|
||||
*/
|
||||
export function debugBlobbiWarn(category: DebugCategory, ...args: unknown[]): void {
|
||||
if (!import.meta.env.DEV || !BLOBBI_DEBUG) return;
|
||||
console.warn(`[blobbi:${category}]`, ...args);
|
||||
}
|
||||
@@ -11,6 +11,21 @@
|
||||
* eliminating duplicate definitions across the codebase.
|
||||
*/
|
||||
|
||||
// ─── Render Mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Controls how the Blobbi visual is rendered.
|
||||
*
|
||||
* - 'page': Default. Runs eye tracking hooks internally, applies reaction
|
||||
* CSS classes on the SVG container. Used by BlobbiStageVisual / BlobbiPage.
|
||||
*
|
||||
* - 'companion': Optimized for the floating companion runtime. Disables
|
||||
* internal mouse tracking (gaze driven by ref), suppresses reaction CSS
|
||||
* classes on the SVG container (applied by outer wrapper instead) to
|
||||
* keep the dangerouslySetInnerHTML node stable.
|
||||
*/
|
||||
export type BlobbiRenderMode = 'page' | 'companion';
|
||||
|
||||
// ─── Eye Tracking Types ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user