fix(blobbi): use RAF loop in useExternalEyeOffset to prevent stuck eyes
The previous useEffect-based approach only applied eye transforms when the externalEyeOffset prop changed. This caused eyes to get stuck in the center when the companion was idle because: - The gaze RAF loop updates eyeOffset state continuously - React batches state updates, so re-renders may not happen every frame - useBlobbiEyes also runs a RAF loop for blinking that could interfere - SVG content changes (emotion recipes) could reset transforms The fix uses a RAF loop that continuously applies the transform, reading the latest offset from a ref. This ensures eyes stay positioned correctly regardless of React render timing or SVG DOM changes. Dragging previously 'fixed' the stuck eyes because isDragging changes caused guaranteed re-renders that triggered the old useEffect.
This commit is contained in:
@@ -6,14 +6,20 @@
|
||||
* instead of the default mouse tracking.
|
||||
*
|
||||
* This hook:
|
||||
* - Runs a RAF loop to continuously apply eye offset
|
||||
* - Queries the DOM for blobbi-eye-left and blobbi-eye-right elements
|
||||
* - Converts -1 to 1 offset to pixel movement
|
||||
* - Applies asymmetric vertical movement (up stronger than down)
|
||||
*
|
||||
* Previously this logic was duplicated in BlobbiBabyVisual and BlobbiAdultVisual.
|
||||
* The RAF loop is necessary because:
|
||||
* - useBlobbiEyes also runs a RAF loop for blinking
|
||||
* - SVG content can change due to emotion recipes
|
||||
* - A useEffect that only runs on prop change can miss DOM updates
|
||||
*
|
||||
* Previously this was a simple useEffect, but that caused stuck eyes when idle.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { ExternalEyeOffset, BlobbiVariant } from './types';
|
||||
import {
|
||||
@@ -40,8 +46,9 @@ interface UseExternalEyeOffsetOptions {
|
||||
/**
|
||||
* Apply external eye offset to Blobbi eye elements.
|
||||
*
|
||||
* This bypasses useBlobbiEyes and gives the external system (e.g., companion)
|
||||
* full control over eye position.
|
||||
* This bypasses useBlobbiEyes tracking and gives the external system (e.g., companion)
|
||||
* full control over eye position. Uses a RAF loop to ensure transforms are continuously
|
||||
* applied even when the SVG DOM changes.
|
||||
*/
|
||||
export function useExternalEyeOffset({
|
||||
containerRef,
|
||||
@@ -49,38 +56,78 @@ export function useExternalEyeOffset({
|
||||
isSleeping,
|
||||
variant,
|
||||
}: UseExternalEyeOffsetOptions): void {
|
||||
// Use ref to store latest offset for RAF loop to read
|
||||
const offsetRef = useRef(externalEyeOffset);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
|
||||
// Keep ref updated with latest value
|
||||
useEffect(() => {
|
||||
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
|
||||
|
||||
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>(`.${EYE_CLASSES.eyeLeft}, .${EYE_CLASSES.eyeRight}`);
|
||||
if (eyeElements.length === 0) return;
|
||||
|
||||
offsetRef.current = externalEyeOffset;
|
||||
}, [externalEyeOffset]);
|
||||
|
||||
// RAF loop for continuous eye offset application
|
||||
useEffect(() => {
|
||||
// Don't run loop if disabled
|
||||
if (!externalEyeOffset || isSleeping) {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Select movement constants based on variant
|
||||
const maxMovementX = variant === 'baby' ? BABY_EXTERNAL_EYE_MAX_X : ADULT_EXTERNAL_EYE_MAX_X;
|
||||
const maxMovementYUp = variant === 'baby' ? BABY_EXTERNAL_EYE_MAX_Y_UP : ADULT_EXTERNAL_EYE_MAX_Y_UP;
|
||||
const maxMovementYDown = variant === 'baby' ? BABY_EXTERNAL_EYE_MAX_Y_DOWN : ADULT_EXTERNAL_EYE_MAX_Y_DOWN;
|
||||
|
||||
// Convert -1 to 1 offset to pixel movement
|
||||
const x = externalEyeOffset.x * maxMovementX;
|
||||
|
||||
// Asymmetric vertical movement:
|
||||
// - Upward (negative y): stronger movement for clear "looking up" effect
|
||||
// - Downward (positive y): reduced movement to avoid looking too droopy
|
||||
// Y offset: -1 = looking up, +1 = looking down
|
||||
const y =
|
||||
externalEyeOffset.y < 0
|
||||
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
|
||||
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
|
||||
|
||||
eyeElements.forEach((el) => {
|
||||
// Check if a CSS animation is controlling this element's transform
|
||||
// (e.g., sleepy wake-up glance). Don't override CSS animations.
|
||||
const animationName = getComputedStyle(el).animationName;
|
||||
if (animationName && animationName !== 'none') {
|
||||
return; // Let CSS animation control the transform
|
||||
|
||||
const applyOffset = () => {
|
||||
const offset = offsetRef.current;
|
||||
if (!offset || !containerRef.current) {
|
||||
animationRef.current = requestAnimationFrame(applyOffset);
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute('transform', `translate(${x} ${y})`);
|
||||
});
|
||||
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>(
|
||||
`.${EYE_CLASSES.eyeLeft}, .${EYE_CLASSES.eyeRight}`
|
||||
);
|
||||
|
||||
if (eyeElements.length > 0) {
|
||||
// Convert -1 to 1 offset to pixel movement
|
||||
const x = offset.x * maxMovementX;
|
||||
|
||||
// Asymmetric vertical movement:
|
||||
// - Upward (negative y): stronger movement for clear "looking up" effect
|
||||
// - Downward (positive y): reduced movement to avoid looking too droopy
|
||||
// Y offset: -1 = looking up, +1 = looking down
|
||||
const y = offset.y < 0
|
||||
? offset.y * maxMovementYUp // Looking up: full range
|
||||
: offset.y * maxMovementYDown; // Looking down: reduced range
|
||||
|
||||
const transform = `translate(${x} ${y})`;
|
||||
|
||||
eyeElements.forEach((el) => {
|
||||
// Check if a CSS animation is controlling this element's transform
|
||||
// (e.g., sleepy wake-up glance). Don't override CSS animations.
|
||||
const animationName = getComputedStyle(el).animationName;
|
||||
if (animationName && animationName !== 'none') {
|
||||
return; // Let CSS animation control the transform
|
||||
}
|
||||
|
||||
el.setAttribute('transform', transform);
|
||||
});
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(applyOffset);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(applyOffset);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [containerRef, externalEyeOffset, isSleeping, variant]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user