Refactor Blobbi visual state system: separate base face from overlay animations

- 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
This commit is contained in:
filemon
2026-03-29 16:36:16 -03:00
parent fd20081ce8
commit 82b2aeb294
5 changed files with 376 additions and 43 deletions
+18 -4
View File
@@ -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<HTMLDivElement>(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),
+18 -4
View File
@@ -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<HTMLDivElement>(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),
+3 -1
View File
@@ -71,8 +71,10 @@ export interface StatusReactionState {
* states where the visual should remain as long as the condition persists.
*/
const PERSISTENT_REACTIONS: Set<BlobbiEmotion> = 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
]);
+239 -28
View File
@@ -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<BlobbiEmotion, EmotionConfig> = {
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 = /<path[^>]*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 `<path
const animatedMouth = `<path
class="blobbi-mouth blobbi-mouth-sleepy"
d="${smilePath}"
${mouth.strokeAttrs || 'stroke="#1f2937" stroke-width="2.5"'}
fill="none"
stroke-linecap="round"
d="${currentMouthPath}"
${restOfAttributes}
>
<animate
attributeName="d"
values="${smilePath};${smilePath};${transitionPath};${uMouthPath};${uMouthPath};${transitionPath};${smilePath};${smilePath}"
values="${currentMouthPath};${currentMouthPath};${transitionPath};${uMouthPath};${uMouthPath};${transitionPath};${currentMouthPath};${currentMouthPath}"
keyTimes="0;0.10;0.25;0.40;0.62;0.75;0.90;1"
dur="${dur}s"
repeatCount="indefinite"
@@ -1562,6 +1642,8 @@ function generateSleepyMouth(mouth: MouthPosition, config: SleepyAnimationConfig
keySplines="0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1"
/>
</path>`;
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(`<path
class="blobbi-dirt-mark blobbi-dirt-mark-${i}"
d="M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}"
stroke="#78716c"
stroke-width="1.5"
stroke-linecap="round"
fill="none"
opacity="0.6"
/>`);
});
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(`<g class="blobbi-stink-cloud blobbi-stink-cloud-${i}" opacity="0">
<path
d="M ${startX} ${startY}
Q ${startX - 1.5} ${startY - 1} ${startX - 2} ${startY - 2}
Q ${startX - 1} ${startY - 3} ${startX} ${startY - 2.5}
Q ${startX + 1} ${startY - 3} ${startX + 2} ${startY - 2}
Q ${startX + 1.5} ${startY - 1} ${startX} ${startY}"
fill="#9ca3af"
opacity="0.5"
/>
<!-- Float up animation -->
<animateTransform
attributeName="transform"
type="translate"
values="0 0; 0 -12"
dur="3s"
begin="${pos.delay}s"
repeatCount="indefinite"
/>
<!-- Fade in and out -->
<animate
attributeName="opacity"
values="0;0.6;0.6;0"
keyTimes="0;0.15;0.7;1"
dur="3s"
begin="${pos.delay}s"
repeatCount="indefinite"
/>
</g>`);
});
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 </svg> tag
if (overlays.length > 0) {
const overlayGroup = `
+98 -6
View File
@@ -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<StatSeverity, number> = {
* 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 ───────────────────────────────────────────────────
/**