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:
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user