From 82b2aeb2940fb27c115ce6221db3408d23fdffde Mon Sep 17 00:00:00 2001 From: filemon Date: Sun, 29 Mar 2026 16:36:16 -0300 Subject: [PATCH] Refactor Blobbi visual state system: separate base face from overlay animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'boring' persistent face: low-energy, unamused expression (replaces sad as generic fallback) - Droopy mouth with shallow curve, flat eyebrows - Used for non-critical bad stats (health, happiness) - Add 'dirty' persistent state: hygiene-specific visuals - Includes dirt marks on lower body (3 curved scratch-like lines) - Animated stink clouds floating upward - Uses boring face as base + hygiene effects - Refactor 'sleepy' to be an overlay animation - KEY FIX: sleepy now animates the CURRENT mouth state instead of resetting to default smile - When Blobbi is unwell (boring/dirty/dizzy face), sleepy animation preserves that base face - Implementation: applySleepyMouthAnimation finds existing mouth path and animates from there - Example: boring face + sleepy = boring expression with sleepy animation on top - Update status-reactions.ts emotion mapping - health: boring (not feeling good) → dizzy (critical) - hygiene: dirty (poor hygiene visuals) - happiness: boring (low energy, unamused) - energy: sleepy (now an overlay, not base-replacing) - Add base + overlay emotion architecture to visual components - BlobbiAdultVisual and BlobbiBabyVisual now accept optional baseEmotion prop - Emotions applied sequentially: base first, then overlay - Preserves existing behavior when only one emotion provided - Add resolveStatusEmotions() utility - Separates base emotions from overlay emotions - Returns StatusEmotionResult with baseEmotion and overlayEmotion - Enables proper multi-stat handling (e.g., low health + low energy = boring face with sleepy overlay) Architecture notes: - Base emotions (boring, dirty, dizzy, sad, happy, etc.): replace face completely - Overlay emotions (sleepy): animate on top without replacing base - Critical fix: Blobbi no longer visually resets to happy during sleepy cycle when in bad state --- src/blobbi/ui/BlobbiAdultVisual.tsx | 22 +- src/blobbi/ui/BlobbiBabyVisual.tsx | 22 +- src/blobbi/ui/hooks/useStatusReaction.ts | 4 +- src/blobbi/ui/lib/emotions.ts | 267 ++++++++++++++++++++--- src/blobbi/ui/lib/status-reactions.ts | 104 ++++++++- 5 files changed, 376 insertions(+), 43 deletions(-) diff --git a/src/blobbi/ui/BlobbiAdultVisual.tsx b/src/blobbi/ui/BlobbiAdultVisual.tsx index 7b1a1cc5..0f09b55b 100644 --- a/src/blobbi/ui/BlobbiAdultVisual.tsx +++ b/src/blobbi/ui/BlobbiAdultVisual.tsx @@ -51,6 +51,13 @@ export interface BlobbiAdultVisualProps { * Default: 'neutral' (no modifications) */ emotion?: BlobbiEmotion; + /** + * Base emotion for overlay animations. + * When emotion is an overlay (like 'sleepy'), this base emotion is applied first, + * then the overlay animates on top of it. + * Example: baseEmotion='boring', emotion='sleepy' → boring face with sleepy animation + */ + baseEmotion?: BlobbiEmotion; /** Additional CSS classes for the container */ className?: string; } @@ -66,7 +73,7 @@ export interface BlobbiAdultVisualProps { * - Eyes always track the mouse cursor (instant, real-time) * - Renders safely using dangerouslySetInnerHTML */ -export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiAdultVisualProps) { +export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', baseEmotion, className }: BlobbiAdultVisualProps) { const isSleeping = isBlobbiSleeping(blobbi); const containerRef = useRef(null); @@ -105,8 +112,15 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo // Pass base color for eyelid generation let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id }); - // Apply emotion overlays (eyebrows, sad mouth, tears, etc.) - // Pass form for form-specific adjustments (e.g., owli/froggi eyebrow positioning) + // Apply base emotion first (if provided) + // Base emotions set the persistent face state (boring, dirty, dizzy, etc.) + if (baseEmotion && baseEmotion !== 'neutral') { + animatedSvg = applyEmotion(animatedSvg, baseEmotion, 'adult', form); + } + + // Apply primary emotion + // If this is an overlay emotion (sleepy), it will animate on top of the base + // If this is a regular emotion and no baseEmotion was provided, it acts as the base if (emotion !== 'neutral') { animatedSvg = applyEmotion(animatedSvg, emotion, 'adult', form); } @@ -115,7 +129,7 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo } return colorizedSvg; - }, [blobbi, isSleeping, emotion]); + }, [blobbi, isSleeping, emotion, baseEmotion]); // Defense-in-depth: sanitize the final SVG before DOM injection. // The upstream pipeline validates inputs (normalizeHexColor, instanceId sanitization), diff --git a/src/blobbi/ui/BlobbiBabyVisual.tsx b/src/blobbi/ui/BlobbiBabyVisual.tsx index 1e9feb56..0998a28f 100644 --- a/src/blobbi/ui/BlobbiBabyVisual.tsx +++ b/src/blobbi/ui/BlobbiBabyVisual.tsx @@ -49,6 +49,13 @@ export interface BlobbiBabyVisualProps { * Default: 'neutral' (no modifications) */ emotion?: BlobbiEmotion; + /** + * Base emotion for overlay animations. + * When emotion is an overlay (like 'sleepy'), this base emotion is applied first, + * then the overlay animates on top of it. + * Example: baseEmotion='boring', emotion='sleepy' → boring face with sleepy animation + */ + baseEmotion?: BlobbiEmotion; /** Additional CSS classes for the container */ className?: string; } @@ -63,7 +70,7 @@ export interface BlobbiBabyVisualProps { * - Eyes always track the mouse cursor (instant, real-time) * - Renders safely using dangerouslySetInnerHTML */ -export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiBabyVisualProps) { +export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', baseEmotion, className }: BlobbiBabyVisualProps) { const isSleeping = isBlobbiSleeping(blobbi); const containerRef = useRef(null); @@ -99,8 +106,15 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow // Pass base color for eyelid generation let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id }); - // Apply emotion overlays (eyebrows, sad mouth, tears, etc.) - // Pass 'baby' variant for baby-specific adjustments (e.g., eyebrow positioning) + // Apply base emotion first (if provided) + // Base emotions set the persistent face state (boring, dirty, dizzy, etc.) + if (baseEmotion && baseEmotion !== 'neutral') { + animatedSvg = applyEmotion(animatedSvg, baseEmotion, 'baby'); + } + + // Apply primary emotion + // If this is an overlay emotion (sleepy), it will animate on top of the base + // If this is a regular emotion and no baseEmotion was provided, it acts as the base if (emotion !== 'neutral') { animatedSvg = applyEmotion(animatedSvg, emotion, 'baby'); } @@ -109,7 +123,7 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow } return colorizedSvg; - }, [blobbi, isSleeping, emotion]); + }, [blobbi, isSleeping, emotion, baseEmotion]); // Defense-in-depth: sanitize the final SVG before DOM injection. // The upstream pipeline validates inputs (normalizeHexColor, instanceId sanitization), diff --git a/src/blobbi/ui/hooks/useStatusReaction.ts b/src/blobbi/ui/hooks/useStatusReaction.ts index 144b937d..6523efb9 100644 --- a/src/blobbi/ui/hooks/useStatusReaction.ts +++ b/src/blobbi/ui/hooks/useStatusReaction.ts @@ -71,8 +71,10 @@ export interface StatusReactionState { * states where the visual should remain as long as the condition persists. */ const PERSISTENT_REACTIONS: Set = new Set([ - 'sleepy', // Energy critical - continuous drowsy state + 'sleepy', // Energy critical - continuous drowsy state (overlay) 'sad', // Unhappy/unhealthy - continuous sadness with tears + 'boring', // Low energy, unamused, mildly down - persistent base face + 'dirty', // Poor hygiene - persistent visual state with dirt/stink 'dizzy', // Health critical - continuous disorientation 'hungry', // Hunger critical - continuous hunger indication ]); diff --git a/src/blobbi/ui/lib/emotions.ts b/src/blobbi/ui/lib/emotions.ts index ee3c7c6c..2366ca29 100644 --- a/src/blobbi/ui/lib/emotions.ts +++ b/src/blobbi/ui/lib/emotions.ts @@ -33,7 +33,7 @@ import { /** * Available emotion states for Blobbies */ -export type BlobbiEmotion = 'neutral' | 'sad' | 'happy' | 'angry' | 'surprised' | 'sleepy' | 'curious' | 'dizzy' | 'excited' | 'excitedB' | 'mischievous' | 'adoring' | 'hungry'; +export type BlobbiEmotion = 'neutral' | 'sad' | 'boring' | 'dirty' | 'happy' | 'angry' | 'surprised' | 'sleepy' | 'curious' | 'dizzy' | 'excited' | 'excitedB' | 'mischievous' | 'adoring' | 'hungry'; /** * Blobbi variant for variant-specific adjustments @@ -74,6 +74,10 @@ export interface EmotionConfig { foodIcon?: FoodIconConfig; /** Droopy/weak mouth (less curved than sad) */ droopyMouth?: DroopyMouthConfig; + /** Dirt marks on body (for dirty emotion) */ + dirtMarks?: DirtMarksConfig; + /** Stink cloud puffs (for dirty emotion) */ + stinkClouds?: StinkCloudsConfig; } export interface PupilModification { @@ -198,6 +202,20 @@ export interface DroopyMouthConfig { curveScale: number; } +export interface DirtMarksConfig { + /** Enable dirt marks on body */ + enabled: boolean; + /** Number of dirt marks (default: 3) */ + count?: number; +} + +export interface StinkCloudsConfig { + /** Enable stink clouds animation */ + enabled: boolean; + /** Number of cloud puffs (default: 3) */ + count?: number; +} + // ─── Emotion Configurations ─────────────────────────────────────────────────── /** @@ -231,6 +249,42 @@ export const EMOTION_CONFIGS: Record = { pauseBetween: 3, // 3 second pause between tears }, }, + boring: { + // Low-energy, unamused, mildly down - not dramatic like sad + // No tears, just tired/unimpressed look + droopyMouth: { + widthScale: 0.9, // Slightly narrower (tired) + curveScale: 0.4, // Very shallow frown (barely there) + }, + eyebrows: { + angle: 0, // Flat eyebrows (no emotion, just tired/bored) + offsetY: -9, // Positioned above eyes + strokeWidth: 1.3, // Thin, subtle + color: '#4b5563', // Lighter gray (less intense than sad) + }, + }, + dirty: { + // Hygiene-related bad state - visual indicators of dirtiness + // Uses the boring face as base, plus dirt/stink effects + droopyMouth: { + widthScale: 0.9, + curveScale: 0.4, + }, + eyebrows: { + angle: 0, + offsetY: -9, + strokeWidth: 1.3, + color: '#4b5563', + }, + dirtMarks: { + enabled: true, + count: 3, // 3 small dirt marks/scratches + }, + stinkClouds: { + enabled: true, + count: 3, // 3 small stink puffs + }, + }, happy: { // The base expression is already happy, so minimal changes needed mouthCurve: 1.2, // Slightly bigger smile if desired @@ -1514,47 +1568,73 @@ function generateSleepyStyles(config: SleepyAnimationConfig): string { /** * Generate animated sleepy mouth that transitions: - * smile → U-shaped sleepy mouth → smile + * current mouth → U-shaped sleepy mouth → current mouth * * Uses SVG SMIL animation on path d attribute for smooth morphing. - * No intermediate flat line - direct transition to the cute U-shaped mouth. + * This works as an OVERLAY - it animates whatever mouth is already present, + * so if Blobbi has a frown from being unhealthy, it will animate: + * frown → U-shaped sleepy mouth → frown (NOT smile!) + * + * @param svgText - The current SVG content with mouth already applied + * @param config - Sleepy animation configuration + * @returns The current mouth path with SMIL animation added */ -function generateSleepyMouth(mouth: MouthPosition, config: SleepyAnimationConfig): string { +function applySleepyMouthAnimation(svgText: string, config: SleepyAnimationConfig): string { const dur = config.cycleDuration; - // Calculate mouth center for U-shaped mouth position - const centerX = (mouth.startX + mouth.endX) / 2; - const baselineY = (mouth.startY + mouth.endY) / 2; + // Find the current mouth path in the SVG + // This could be the default smile, a sad frown, a droopy mouth, etc. + // We want to animate FROM this current state, not replace it + const mouthRegex = /]*class="[^"]*blobbi-mouth[^"]*"[^>]*d="([^"]+)"([^>]*)\/>/; + const match = svgText.match(mouthRegex); - // 1. Original smile path - const smilePath = `M ${mouth.startX} ${mouth.startY} Q ${mouth.controlX} ${mouth.controlY} ${mouth.endX} ${mouth.endY}`; + if (!match) { + // No mouth found, can't apply animation + return svgText; + } + + const [fullMatch, currentMouthPath, restOfAttributes] = match; + + // Parse the current mouth to get its center point + // Extract coordinates from the path (M x y Q cx cy ex ey) + const coordMatch = currentMouthPath.match(/M\s*([\d.]+)\s+([\d.]+)\s*Q\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/); + if (!coordMatch) { + return svgText; + } + + const startX = parseFloat(coordMatch[1]); + const startY = parseFloat(coordMatch[2]); + const endX = parseFloat(coordMatch[5]); + const endY = parseFloat(coordMatch[6]); + + // Calculate center for the U-shaped sleepy mouth + const centerX = (startX + endX) / 2; + const baselineY = (startY + endY) / 2; // 2. U-shaped sleepy mouth (small, cute, rounded) const roundRadius = 3; const uMouthPath = `M ${centerX - roundRadius} ${baselineY} Q ${centerX - roundRadius} ${baselineY + roundRadius * 1.5} ${centerX} ${baselineY + roundRadius * 1.5} Q ${centerX + roundRadius} ${baselineY + roundRadius * 1.5} ${centerX + roundRadius} ${baselineY}`; - // Intermediate: smile that's starting to close (less wide, transitioning toward U) - const transitionPath = `M ${centerX - roundRadius * 2} ${baselineY} Q ${centerX} ${mouth.controlY * 0.7 + baselineY * 0.3} ${centerX + roundRadius * 2} ${baselineY}`; + // Intermediate: transitioning toward U (narrower than current, but not fully U yet) + const transitionPath = `M ${centerX - roundRadius * 2} ${baselineY} Q ${centerX} ${baselineY + roundRadius * 0.8} ${centerX + roundRadius * 2} ${baselineY}`; - // Timeline - direct smile to U-shaped transition: - // 0-10%: smile (awake) - // 10-25%: smile → transition (getting sleepy) + // Timeline - animate from CURRENT mouth to U-shaped and back: + // 0-10%: current mouth (awake) + // 10-25%: current → transition (getting sleepy) // 25-40%: transition → U-shaped (falling asleep) // 40-62%: U-shaped (asleep) // 62-75%: U-shaped → transition (waking) - // 75-90%: transition → smile - // 90-100%: smile (awake, glancing) + // 75-90%: transition → current mouth + // 90-100%: current mouth (awake, glancing) - return ` `; + + return svgText.replace(fullMatch, animatedMouth); } /** @@ -1692,11 +1774,9 @@ function applySleepyAnimation(svgText: string, eyes: EyePosition[], mouth: Mouth // Add SMIL animations to clip-path rects for eye closing effect svgText = generateSleepyClipAnimations(svgText, config); - // Replace mouth with animated sleepy mouth - if (mouth) { - const sleepyMouthSvg = generateSleepyMouth(mouth.position, config); - svgText = replaceMouthSection(svgText, sleepyMouthSvg); - } + // Apply sleepy mouth animation (animates the CURRENT mouth, doesn't replace it) + // This is the key change - sleepy now works as an OVERLAY on the existing mouth + svgText = applySleepyMouthAnimation(svgText, config); // Generate overlays (closed eye lines + Zzz) const closedEyeLines = generateClosedEyeLines(eyes); @@ -1715,6 +1795,103 @@ function applySleepyAnimation(svgText: string, eyes: EyePosition[], mouth: Mouth return svgText; } +// ─── Dirt Marks Generation ──────────────────────────────────────────────────── + +/** + * Generate dirt marks/scratches on the lower body. + * Creates small curved lines that look like dirt or scratches. + */ +function generateDirtMarks(config: DirtMarksConfig): string { + const count = config.count || 3; + const marks: string[] = []; + + // Place dirt marks in the lower portion of Blobbi (y: 70-85 for 100x100 viewBox) + // Randomized but deterministic positions for consistency + const positions = [ + { x: 35, y: 75, angle: 15, length: 4 }, + { x: 55, y: 80, angle: -10, length: 3.5 }, + { x: 45, y: 72, angle: 5, length: 3 }, + ].slice(0, count); + + positions.forEach((pos, i) => { + // Create a short curved line + const startX = pos.x; + const startY = pos.y; + const endX = startX + pos.length * Math.cos((pos.angle * Math.PI) / 180); + const endY = startY + pos.length * Math.sin((pos.angle * Math.PI) / 180); + const controlX = (startX + endX) / 2 + 1; + const controlY = (startY + endY) / 2 - 0.5; + + marks.push(``); + }); + + return marks.join('\n'); +} + +// ─── Stink Clouds Generation ────────────────────────────────────────────────── + +/** + * Generate animated stink cloud puffs below the Blobbi. + * Creates small wavy lines that float upward to indicate poor hygiene. + */ +function generateStinkClouds(config: StinkCloudsConfig): string { + const count = config.count || 3; + const clouds: string[] = []; + + // Stink clouds appear below and around Blobbi (y: 85-95 for 100x100 viewBox) + const positions = [ + { x: 38, y: 88, delay: 0 }, + { x: 50, y: 90, delay: 0.8 }, + { x: 62, y: 87, delay: 1.6 }, + ].slice(0, count); + + positions.forEach((pos, i) => { + // Create a small wavy cloud shape using curves + const startX = pos.x; + const startY = pos.y; + + clouds.push(` + + + + + + `); + }); + + return clouds.join('\n'); +} + // ─── Main Emotion Application ───────────────────────────────────────────────── /** @@ -1723,13 +1900,37 @@ function applySleepyAnimation(svgText: string, eyes: EyePosition[], mouth: Mouth * This function adds emotion-specific elements (eyebrows, modified mouth, tears) * without modifying the base SVG structure. * + * ARCHITECTURAL NOTE - Base vs Overlay Emotions: + * + * The emotion system now has two layers: + * + * 1. BASE EMOTIONS (persistent face): + * - boring, dirty, dizzy, sad, happy, angry, hungry, etc. + * - These REPLACE the face completely (mouth, eyebrows, sometimes eyes) + * - They represent the Blobbi's ongoing condition + * + * 2. OVERLAY EMOTIONS (temporary animations): + * - sleepy (currently the only overlay) + * - These ANIMATE on top of the existing face without replacing it + * - sleepy animates whatever mouth is already present (smile → U → smile, or frown → U → frown) + * + * The sleepy overlay works by: + * - Finding the current mouth path in the SVG (which may be a smile, frown, droopy mouth, etc.) + * - Animating from current mouth → U-shaped sleepy mouth → back to current mouth + * - This means sleepy preserves the base emotional state visually + * * @param svgText - The base SVG content * @param emotion - The emotion to apply * @param variant - The Blobbi variant (baby/adult) for variant-specific adjustments * @param form - Optional adult form for form-specific adjustments (e.g., owli, froggi) * @returns Modified SVG with emotion overlays */ -export function applyEmotion(svgText: string, emotion: BlobbiEmotion, variant: BlobbiVariant = 'adult', form?: string): string { +export function applyEmotion( + svgText: string, + emotion: BlobbiEmotion, + variant: BlobbiVariant = 'adult', + form?: string +): string { if (emotion === 'neutral') { return svgText; } @@ -1904,6 +2105,16 @@ export function applyEmotion(svgText: string, emotion: BlobbiEmotion, variant: B overlays.push(generateFoodIcon(config.foodIcon)); } + // Generate dirt marks (for dirty) + if (config.dirtMarks?.enabled) { + overlays.push(generateDirtMarks(config.dirtMarks)); + } + + // Generate stink clouds (for dirty) + if (config.stinkClouds?.enabled) { + overlays.push(generateStinkClouds(config.stinkClouds)); + } + // Insert overlays before closing tag if (overlays.length > 0) { const overlayGroup = ` diff --git a/src/blobbi/ui/lib/status-reactions.ts b/src/blobbi/ui/lib/status-reactions.ts index a76b2517..25f4eccf 100644 --- a/src/blobbi/ui/lib/status-reactions.ts +++ b/src/blobbi/ui/lib/status-reactions.ts @@ -84,6 +84,20 @@ export interface StatusReactionResult { cooldownMs: number; } +/** + * Result of resolving emotions with base + overlay separation. + */ +export interface StatusEmotionResult { + /** The base/persistent emotion (null = neutral/default) */ + baseEmotion: BlobbiEmotion | null; + /** The overlay emotion (null = none) */ + overlayEmotion: BlobbiEmotion | null; + /** The stat that triggered the base emotion */ + triggeringBaseStat: ReactiveStat | null; + /** The stat that triggered the overlay emotion */ + triggeringOverlayStat: ReactiveStat | null; +} + // ─── Constants ──────────────────────────────────────────────────────────────── /** @@ -112,6 +126,12 @@ export const TRIGGER_PROBABILITIES: Record = { * Stat reaction configurations. * Priority order: energy > health > hunger > hygiene > happiness * Lower priority number = checked first (wins ties). + * + * Updated emotion mapping: + * - boring: low-energy, unamused state (replaces sad as generic fallback) + * - dirty: hygiene-specific state with visual dirt/stink effects + * - dizzy: critical health state only + * - sad: reserved for dramatic emotional distress (not used by stats currently) */ export const STAT_REACTION_CONFIGS: StatReactionConfig[] = [ { @@ -119,27 +139,28 @@ export const STAT_REACTION_CONFIGS: StatReactionConfig[] = [ priority: 1, normalReaction: 'sleepy', // Energy doesn't have a distinct critical reaction + // sleepy is now an OVERLAY that preserves the base face }, { stat: 'health', priority: 2, - normalReaction: 'sad', - criticalReaction: 'dizzy', // Critical health shows dizzy instead of sad + normalReaction: 'boring', // Non-critical health shows boring (not feeling good) + criticalReaction: 'dizzy', // Critical health shows dizzy (seriously unwell) }, { stat: 'hunger', priority: 3, - normalReaction: 'hungry', + normalReaction: 'hungry', // Hungry emotion already has appropriate visuals }, { stat: 'hygiene', priority: 4, - normalReaction: 'sad', + normalReaction: 'dirty', // Hygiene shows dirty with stink clouds/dirt marks }, { stat: 'happiness', priority: 5, - normalReaction: 'sad', + normalReaction: 'boring', // Low happiness shows boring (low energy, unamused) }, ]; @@ -281,7 +302,7 @@ export function resolveStatusReaction( * Useful for determining if a reaction should be overridden. */ export function isStatusReaction(emotion: BlobbiEmotion): boolean { - const statusEmotions: BlobbiEmotion[] = ['sleepy', 'hungry', 'sad', 'dizzy']; + const statusEmotions: BlobbiEmotion[] = ['sleepy', 'hungry', 'boring', 'dirty', 'dizzy']; return statusEmotions.includes(emotion); } @@ -292,6 +313,77 @@ export function getDefaultEmotion(): BlobbiEmotion { return 'neutral'; // Base happy expression } +/** + * Resolve status emotions with base + overlay separation. + * + * This is the NEW recommended way to resolve emotions from stats. + * It properly separates: + * - BASE emotions (persistent face: boring, dirty, dizzy, hungry, etc.) + * - OVERLAY emotions (temporary animations: sleepy) + * + * Example: + * - If energy is low AND health is low: + * - Base: boring (from health) + * - Overlay: sleepy (from energy) + * - Result: Blobbi has a boring/tired face with sleepy animation on top + * + * @param stats - Current Blobbi stats + * @returns Base emotion and optional overlay emotion + */ +export function resolveStatusEmotions(stats: BlobbiStats): StatusEmotionResult { + const analyses = analyzeAllStats(stats); + + // No stats are low enough to trigger + if (analyses.length === 0) { + return { + baseEmotion: null, + overlayEmotion: null, + triggeringBaseStat: null, + triggeringOverlayStat: null, + }; + } + + // Separate overlay emotions (sleepy) from base emotions + const sleepyAnalysis = analyses.find(a => a.reaction === 'sleepy'); + const baseAnalyses = analyses.filter(a => a.reaction !== 'sleepy'); + + // Get the highest priority base emotion (if any) + const baseWinner = baseAnalyses.length > 0 ? baseAnalyses[0] : null; + + return { + baseEmotion: baseWinner?.reaction ?? null, + overlayEmotion: sleepyAnalysis?.reaction ?? null, + triggeringBaseStat: baseWinner?.stat ?? null, + triggeringOverlayStat: sleepyAnalysis?.stat ?? null, + }; +} + +/** + * Combine base and overlay emotions into a single emotion for the visual system. + * + * The visual system (BlobbiAdultVisual/BlobbiBabyVisual) applies emotions sequentially: + * 1. Apply base emotion (changes face: mouth, eyes, eyebrows) + * 2. Apply overlay emotion (adds animations on top without replacing the face) + * + * When both are present, overlay takes precedence as the "primary" emotion, + * but the applyEmotion function internally applies the base first. + * + * @param result - Result from resolveStatusEmotions + * @returns The emotion to pass to visual components + */ +export function combineEmotions(result: StatusEmotionResult): BlobbiEmotion { + // If we have both base and overlay, return overlay (which will apply base first internally) + // If we only have overlay (shouldn't happen, but handle it), return overlay + // If we only have base, return base + // If neither, return neutral + + if (result.overlayEmotion) { + return result.overlayEmotion; + } + + return result.baseEmotion ?? 'neutral'; +} + // ─── Action Emotion Mapping ─────────────────────────────────────────────────── /**