From f9c8bbc4cc073edf0ee37db8c124d7645903e887 Mon Sep 17 00:00:00 2001 From: filemon Date: Fri, 27 Mar 2026 11:44:30 -0300 Subject: [PATCH] Add eyelid background layer and update excited emotion with star eyes Eyelid layer: - Add blobbi-eyelid ellipse behind each eye white - Derive color from base body color (darkened by 15%) - Pass baseColor to addEyeAnimation from visual components - Ready for future blink animation integration Excited emotion: - Replace eyes with 5-pointed golden stars - Add big smile (30% wider, 40% deeper curve) - Hide normal eyes when stars are active - Clean, readable across baby and adult variants --- src/blobbi/ui/BlobbiAdultVisual.tsx | 3 +- src/blobbi/ui/BlobbiBabyVisual.tsx | 3 +- src/blobbi/ui/lib/emotions.ts | 198 +++++++++++++++++++++++++--- src/blobbi/ui/lib/eye-animation.ts | 121 +++++++++++++++-- 4 files changed, 296 insertions(+), 29 deletions(-) diff --git a/src/blobbi/ui/BlobbiAdultVisual.tsx b/src/blobbi/ui/BlobbiAdultVisual.tsx index 15f38e9d..cdfe285d 100644 --- a/src/blobbi/ui/BlobbiAdultVisual.tsx +++ b/src/blobbi/ui/BlobbiAdultVisual.tsx @@ -125,7 +125,8 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo // Add eye animation wrappers when awake (eyes are closed when sleeping) if (!isSleeping) { - let animatedSvg = addEyeAnimation(colorizedSvg); + // Pass base color for eyelid generation + let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor }); // Apply emotion overlays (eyebrows, sad mouth, tears, etc.) if (emotion !== 'neutral') { diff --git a/src/blobbi/ui/BlobbiBabyVisual.tsx b/src/blobbi/ui/BlobbiBabyVisual.tsx index 36bfad75..c50b4eb7 100644 --- a/src/blobbi/ui/BlobbiBabyVisual.tsx +++ b/src/blobbi/ui/BlobbiBabyVisual.tsx @@ -119,7 +119,8 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow // Add eye animation wrappers (only when not sleeping) if (!isSleeping) { - let animatedSvg = addEyeAnimation(colorizedSvg); + // Pass base color for eyelid generation + let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor }); // Apply emotion overlays (eyebrows, sad mouth, tears, etc.) // Pass 'baby' variant for baby-specific adjustments (e.g., eyebrow positioning) diff --git a/src/blobbi/ui/lib/emotions.ts b/src/blobbi/ui/lib/emotions.ts index 42fabc17..01a0940c 100644 --- a/src/blobbi/ui/lib/emotions.ts +++ b/src/blobbi/ui/lib/emotions.ts @@ -50,6 +50,10 @@ export interface EmotionConfig { animatedEyebrows?: AnimatedEyebrowsConfig; /** Small/smug smile (for mischievous) */ smallSmile?: SmallSmileConfig; + /** Star eyes effect (replaces normal eyes with stars) */ + starEyes?: StarEyesConfig; + /** Big smile mouth (wider than normal) */ + bigSmile?: BigSmileConfig; } export interface PupilModification { @@ -133,6 +137,24 @@ export interface SmallSmileConfig { scale: number; } +export interface StarEyesConfig { + /** Enable star eyes effect */ + enabled: boolean; + /** Number of points on the star (default: 5) */ + points?: number; + /** Fill color for the stars (default: golden yellow) */ + color?: string; + /** Scale factor relative to pupil size (default: 1.5) */ + scale?: number; +} + +export interface BigSmileConfig { + /** Scale factor for the smile width (1.0 = normal, 1.5 = 50% wider) */ + widthScale: number; + /** Scale factor for the smile curve depth (1.0 = normal, 1.5 = deeper curve) */ + curveScale: number; +} + // ─── Emotion Configurations ─────────────────────────────────────────────────── /** @@ -238,25 +260,17 @@ export const EMOTION_CONFIGS: Record = { }, }, excited: { - // Normal eyes (no watery effect) - // Use the same eyebrow config as sad, but with animation - eyebrows: { - angle: -15, // Same as sad - offsetY: -10, // Same as sad - strokeWidth: 1.5, // Same as sad - color: '#374151', // Same as sad - }, - // Animated eyebrows bouncing up and down - animatedEyebrows: { + // Star eyes replace normal pupils + starEyes: { enabled: true, - bounceDuration: 0.5, // Fast, energetic bounce - bounceAmount: 3, // Pixels to move up + points: 5, // 5-pointed star + color: '#fbbf24', // Golden yellow (amber-400) + scale: 1.4, // Slightly larger than pupils }, - // Curious round mouth - roundMouth: { - rx: 3, // Same as curious - ry: 3.5, - filled: true, + // Big happy smile + bigSmile: { + widthScale: 1.3, // 30% wider + curveScale: 1.4, // 40% deeper curve for extra happy look }, }, mischievous: { @@ -1319,6 +1333,143 @@ function generateSmallSmile(mouth: MouthPosition, config: SmallSmileConfig): str />`; } +// ─── Star Eyes Generation ───────────────────────────────────────────────────── + +/** + * Create a 5-pointed star path centered at (cx, cy) with given size. + * Uses the standard star polygon formula. + */ +function createStarPath(cx: number, cy: number, outerRadius: number, innerRadius: number, points: number = 5): string { + const pathPoints: string[] = []; + const angleOffset = -Math.PI / 2; // Start from top + + for (let i = 0; i < points * 2; i++) { + const angle = angleOffset + (i * Math.PI) / points; + const radius = i % 2 === 0 ? outerRadius : innerRadius; + const x = cx + radius * Math.cos(angle); + const y = cy + radius * Math.sin(angle); + + if (i === 0) { + pathPoints.push(`M ${x.toFixed(2)} ${y.toFixed(2)}`); + } else { + pathPoints.push(`L ${x.toFixed(2)} ${y.toFixed(2)}`); + } + } + + pathPoints.push('Z'); // Close the path + return pathPoints.join(' '); +} + +/** + * Generate star shapes for the excited star eyes effect. + * These replace the normal pupils with cute stars. + */ +function generateStarEyes(eyes: EyePosition[], config: StarEyesConfig): string { + const color = config.color || '#fbbf24'; // Default: amber-400 + const scale = config.scale || 1.4; + const points = config.points || 5; + + return eyes.map(eye => { + // Star size based on pupil radius + const outerRadius = eye.radius * scale; + const innerRadius = outerRadius * 0.4; // Inner radius is 40% of outer for nice star shape + + const starPath = createStarPath(eye.cx, eye.cy, outerRadius, innerRadius, points); + + return ` + + + + `; + }).join('\n'); +} + +/** + * Generate CSS styles for star eyes effect. + * Hides the normal eyes when star eyes are shown. + */ +function generateStarEyesStyles(): string { + return ` + `; +} + +/** + * Apply star eyes effect to the SVG. + */ +function applyStarEyes(svgText: string, eyes: EyePosition[], config: StarEyesConfig): string { + // Add 'blobbi-star-eyes' class to SVG root + svgText = svgText.replace(/]*)>/, (match, attrs) => { + if (attrs.includes('class="')) { + return match.replace(/class="([^"]*)"/, 'class="$1 blobbi-star-eyes"'); + } else if (attrs.includes("class='")) { + return match.replace(/class='([^']*)'/, "class='$1 blobbi-star-eyes'"); + } else { + return ``; + } + }); + + // Add star eyes styles + const starStyles = generateStarEyesStyles(); + if (svgText.includes('')) { + svgText = svgText.replace('', '' + starStyles); + } else { + svgText = svgText.replace(/(]*>)/, '$1' + starStyles); + } + + // Generate star overlays + const stars = generateStarEyes(eyes, config); + + // Insert stars before closing tag + const starOverlay = ` + + + ${stars} + `; + + svgText = svgText.replace('', starOverlay + '\n'); + + return svgText; +} + +// ─── Big Smile Generation ───────────────────────────────────────────────────── + +/** + * Generate a bigger/wider smile by scaling the original mouth. + */ +function generateBigSmile(mouth: MouthPosition, config: BigSmileConfig): string { + const centerX = (mouth.startX + mouth.endX) / 2; + const baselineY = (mouth.startY + mouth.endY) / 2; + + // Scale the mouth width + const halfWidth = (mouth.endX - mouth.startX) / 2; + const scaledHalfWidth = halfWidth * config.widthScale; + const scaledStartX = centerX - scaledHalfWidth; + const scaledEndX = centerX + scaledHalfWidth; + + // Scale the curve depth (how far down the smile curves) + const curveDepth = mouth.controlY - baselineY; + const scaledCurveDepth = curveDepth * config.curveScale; + const scaledControlY = baselineY + scaledCurveDepth; + + return ``; +} + // ─── Sleepy Animation Generation ────────────────────────────────────────────── /** @@ -1702,6 +1853,17 @@ export function applyEmotion(svgText: string, emotion: BlobbiEmotion, variant: B svgText = replaceMouthSection(svgText, smallSmileSvg); } + // Generate star eyes (for excited) + if (config.starEyes?.enabled && eyes.length > 0) { + svgText = applyStarEyes(svgText, eyes, config.starEyes); + } + + // Generate big smile (for excited) + if (config.bigSmile && mouth) { + const bigSmileSvg = generateBigSmile(mouth.position, config.bigSmile); + svgText = replaceMouthSection(svgText, bigSmileSvg); + } + // Insert overlays before closing tag if (overlays.length > 0) { const overlayGroup = ` @@ -1721,5 +1883,5 @@ export function applyEmotion(svgText: string, emotion: BlobbiEmotion, variant: B */ export function emotionAffectsEyes(emotion: BlobbiEmotion): boolean { const config = EMOTION_CONFIGS[emotion]; - return !!(config?.pupilModification); + return !!(config?.pupilModification || config?.starEyes?.enabled || config?.dizzyEffect?.enabled); } diff --git a/src/blobbi/ui/lib/eye-animation.ts b/src/blobbi/ui/lib/eye-animation.ts index 36aeb49d..3d7501a3 100644 --- a/src/blobbi/ui/lib/eye-animation.ts +++ b/src/blobbi/ui/lib/eye-animation.ts @@ -3,11 +3,13 @@ * * Transforms SVG content to add eye animation capability. * - * Two separate animation layers: - * 1. TRACKING: Wraps pupil + highlight in for mouse following - * 2. BLINKING: Wraps entire eye (white + pupil + highlight) in for blink + * Three layers per eye: + * 1. EYELID BACK: Ellipse behind eye white, matches eye white shape, darker body color + * 2. TRACKING: Wraps pupil + highlight in for mouse following + * 3. BLINKING: Wraps entire eye (white + pupil + highlight) in for blink * * This separation ensures: + * - Eyelid back is visible when eyes close (via blink scaleY) * - Only pupil/highlight move when tracking mouse * - Entire eye closes when blinking * - Eye white stays fixed during mouse tracking @@ -45,6 +47,8 @@ interface FullEyeGroup { blinkCenterX: number; /** Blink center Y - from eye white if available, otherwise from pupil */ blinkCenterY: number; + /** Eye white geometry for eyelid generation (rx, ry for ellipse) */ + eyeWhiteGeometry: { rx: number; ry: number } | null; } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -52,9 +56,37 @@ interface FullEyeGroup { // Dark colors used for pupils const PUPIL_COLORS = ['#1f2937', '#374151', '#1e293b', '#111827', '#0f172a', '#64748b']; +// Default eyelid color (used when no base color is provided) +const DEFAULT_EYELID_COLOR = '#6d28d9'; + // Max distance for elements to belong to the same eye const EYE_PROXIMITY = 15; +// How much to darken the base color for eyelids (0-100) +const EYELID_DARKEN_AMOUNT = 15; + +// ─── Color Helpers ──────────────────────────────────────────────────────────── + +/** + * Darken a hex color by a percentage + */ +function darkenColor(color: string, percent: number): string { + if (!color.startsWith('#')) return color; + + const num = parseInt(color.slice(1), 16); + const amt = Math.round(2.55 * percent); + const R = Math.max(0, (num >> 16) - amt); + const G = Math.max(0, ((num >> 8) & 0x00FF) - amt); + const B = Math.max(0, (num & 0x0000FF) - amt); + + return '#' + ( + 0x1000000 + + R * 0x10000 + + G * 0x100 + + B + ).toString(16).slice(1).toUpperCase(); +} + // ─── Detection Helpers ──────────────────────────────────────────────────────── /** @@ -240,6 +272,23 @@ function groupFullEyes(elements: ElementInfo[]): FullEyeGroup[] { // Use eye white center for blink anchor (more accurate), fallback to pupil const blinkAnchor = closestEyeWhite || pupil; + // Extract eye white geometry (rx, ry) for eyelid generation + let eyeWhiteGeometry: { rx: number; ry: number } | null = null; + if (closestEyeWhite) { + const rxMatch = closestEyeWhite.match.match(/rx="(\d+\.?\d*)"/); + const ryMatch = closestEyeWhite.match.match(/ry="(\d+\.?\d*)"/); + const rMatch = closestEyeWhite.match.match(/\br="(\d+\.?\d*)"/); + + if (rxMatch && ryMatch) { + // Ellipse: use rx and ry + eyeWhiteGeometry = { rx: parseFloat(rxMatch[1]), ry: parseFloat(ryMatch[1]) }; + } else if (rMatch) { + // Circle: use r for both + const r = parseFloat(rMatch[1]); + eyeWhiteGeometry = { rx: r, ry: r }; + } + } + groups.push({ eyeWhite: closestEyeWhite, pupil, @@ -247,22 +296,57 @@ function groupFullEyes(elements: ElementInfo[]): FullEyeGroup[] { side: pupil.cx < midX ? 'left' : 'right', blinkCenterX: blinkAnchor.cx, blinkCenterY: blinkAnchor.cy, + eyeWhiteGeometry, }); } return groups; } +// ─── Eyelid Generation ──────────────────────────────────────────────────────── + +/** + * Generate an eyelid background ellipse that matches the eye white shape. + * This sits behind the eye and becomes visible when the eye closes (blink). + */ +function generateEyelidElement( + cx: number, + cy: number, + rx: number, + ry: number, + side: 'left' | 'right', + color: string +): string { + return ``; +} + // ─── SVG Transformation ─────────────────────────────────────────────────────── +/** + * Options for eye animation + */ +export interface EyeAnimationOptions { + /** Base body color for deriving eyelid color (optional) */ + baseColor?: string; +} + /** * Add eye animation capability to SVG content. * - * Creates two nested groups per eye: - * 1. Outer group (blobbi-blink): wraps entire eye for blink animation (scaleY) - * 2. Inner group (blobbi-eye): wraps only pupil+highlight for mouse tracking (translate) + * Creates layers per eye: + * 1. Eyelid back (blobbi-eyelid): ellipse behind eye, darker body color + * 2. Outer group (blobbi-blink): wraps eye white + tracking group for blink animation (scaleY) + * 3. Inner group (blobbi-eye): wraps only pupil+highlight for mouse tracking (translate) * * Structure: + * * * * @@ -271,7 +355,7 @@ function groupFullEyes(elements: ElementInfo[]): FullEyeGroup[] { * * */ -export function addEyeAnimation(svgText: string): string { +export function addEyeAnimation(svgText: string, options?: EyeAnimationOptions): string { const elements = parseElements(svgText); const eyeGroups = groupFullEyes(elements); @@ -287,6 +371,10 @@ export function addEyeAnimation(svgText: string): string { const operations: Operation[] = []; + // Derive eyelid color from base color (or use default) + const baseColor = options?.baseColor || DEFAULT_EYELID_COLOR; + const eyelidColor = darkenColor(baseColor, EYELID_DARKEN_AMOUNT); + for (const group of eyeGroups) { // Collect all elements for this eye (for removal tracking) const allElements: ElementInfo[] = [group.pupil, ...group.highlights]; @@ -326,12 +414,27 @@ export function addEyeAnimation(svgText: string): string { ${blinkContent} `; - // First element gets replaced with the full structure + // Generate eyelid element (behind the eye) if we have geometry + let fullReplacement = blinkGroup; + if (group.eyeWhiteGeometry && group.eyeWhite) { + const eyelidElement = generateEyelidElement( + group.blinkCenterX, + group.blinkCenterY, + group.eyeWhiteGeometry.rx, + group.eyeWhiteGeometry.ry, + group.side, + eyelidColor + ); + // Eyelid goes BEFORE the blink group (so it's behind) + fullReplacement = eyelidElement + '\n ' + blinkGroup; + } + + // First element gets replaced with the full structure (eyelid + blink group) operations.push({ type: 'replace', index: first.index, endIndex: first.endIndex, - replacement: blinkGroup, + replacement: fullReplacement, }); // Remaining elements get removed