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:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 = `
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user