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
This commit is contained in:
filemon
2026-03-27 11:44:30 -03:00
parent 3634440c8b
commit f9c8bbc4cc
4 changed files with 296 additions and 29 deletions
+2 -1
View File
@@ -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') {
+2 -1
View File
@@ -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)
+180 -18
View File
@@ -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<BlobbiEmotion, EmotionConfig> = {
},
},
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 `<g class="blobbi-star-eye blobbi-star-eye-${eye.side}">
<path
d="${starPath}"
fill="${color}"
stroke="#f59e0b"
stroke-width="0.5"
/>
<!-- Small highlight on star -->
<circle cx="${eye.cx - outerRadius * 0.2}" cy="${eye.cy - outerRadius * 0.3}" r="${outerRadius * 0.15}" fill="white" opacity="0.7" />
</g>`;
}).join('\n');
}
/**
* Generate CSS styles for star eyes effect.
* Hides the normal eyes when star eyes are shown.
*/
function generateStarEyesStyles(): string {
return `
<style type="text/css">
/* Hide normal eyes when star eyes are active */
.blobbi-star-eyes .blobbi-blink {
opacity: 0;
}
</style>`;
}
/**
* 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(/<svg([^>]*)>/, (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 `<svg${attrs} class="blobbi-star-eyes">`;
}
});
// Add star eyes styles
const starStyles = generateStarEyesStyles();
if (svgText.includes('<defs>')) {
svgText = svgText.replace('<defs>', '<defs>' + starStyles);
} else {
svgText = svgText.replace(/(<svg[^>]*>)/, '$1' + starStyles);
}
// Generate star overlays
const stars = generateStarEyes(eyes, config);
// Insert stars before closing </svg> tag
const starOverlay = `
<!-- Excited star eyes -->
<g class="blobbi-star-eyes-group">
${stars}
</g>`;
svgText = svgText.replace('</svg>', starOverlay + '\n</svg>');
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 `<path
class="blobbi-mouth blobbi-mouth-big"
d="M ${scaledStartX} ${baselineY} Q ${centerX} ${scaledControlY} ${scaledEndX} ${baselineY}"
${mouth.strokeAttrs || 'stroke="#1f2937" stroke-width="2.5"'}
fill="none"
stroke-linecap="round"
/>`;
}
// ─── 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 </svg> 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);
}
+112 -9
View File
@@ -3,11 +3,13 @@
*
* Transforms SVG content to add eye animation capability.
*
* Two separate animation layers:
* 1. TRACKING: Wraps pupil + highlight in <g class="blobbi-eye"> for mouse following
* 2. BLINKING: Wraps entire eye (white + pupil + highlight) in <g class="blobbi-blink"> 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 <g class="blobbi-eye"> for mouse following
* 3. BLINKING: Wraps entire eye (white + pupil + highlight) in <g class="blobbi-blink"> 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 `<ellipse
class="blobbi-eyelid blobbi-eyelid-${side}"
cx="${cx}"
cy="${cy}"
rx="${rx}"
ry="${ry}"
fill="${color}"
/>`;
}
// ─── 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:
* <ellipse class="blobbi-eyelid" ... /> <!-- eyelid back - behind everything -->
* <g class="blobbi-blink blobbi-blink-left"> <!-- blink: scaleY -->
* <ellipse ... /> <!-- eye white - NOT tracked -->
* <g class="blobbi-eye blobbi-eye-left"> <!-- tracking: translate -->
@@ -271,7 +355,7 @@ function groupFullEyes(elements: ElementInfo[]): FullEyeGroup[] {
* </g>
* </g>
*/
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}
</g>`;
// 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