diff --git a/src/blobbi/companion/components/BlobbiCompanion.tsx b/src/blobbi/companion/components/BlobbiCompanion.tsx index 165161ae..ab302de6 100644 --- a/src/blobbi/companion/components/BlobbiCompanion.tsx +++ b/src/blobbi/companion/components/BlobbiCompanion.tsx @@ -28,6 +28,7 @@ import { } from '../utils/animation'; import { BlobbiCompanionVisual } from './BlobbiCompanionVisual'; import { useClickDetection } from '../interaction'; +import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; interface BlobbiCompanionProps { /** Companion data */ @@ -58,6 +59,10 @@ interface BlobbiCompanionProps { onEndDrag: () => void; /** Click callback (when interaction is a click, not a drag) */ onClick?: () => void; + /** Base emotion for persistent face state (boring, dirty, dizzy, hungry) */ + baseEmotion?: BlobbiEmotion; + /** Overlay emotion (sleepy, action override, etc.) */ + emotion?: BlobbiEmotion; /** Callback to report rendered position (including animations) */ onPositionUpdate?: (position: Position) => void; /** Debug mode - disables animations and shows visual debug aids */ @@ -79,6 +84,8 @@ export function BlobbiCompanion({ onUpdateDrag, onEndDrag, onClick, + baseEmotion, + emotion, onPositionUpdate, debugMode = false, }: BlobbiCompanionProps) { @@ -316,6 +323,8 @@ export function BlobbiCompanion({ floatOffset={floatOffset} isOnGround={isOnGround} distanceFromGround={distanceFromGround} + baseEmotion={baseEmotion} + emotion={emotion} debugMode={debugMode} /> diff --git a/src/blobbi/companion/components/BlobbiCompanionLayer.tsx b/src/blobbi/companion/components/BlobbiCompanionLayer.tsx index cbd9f3e7..f9e536d7 100644 --- a/src/blobbi/companion/components/BlobbiCompanionLayer.tsx +++ b/src/blobbi/companion/components/BlobbiCompanionLayer.tsx @@ -14,13 +14,16 @@ * - Selecting an action shows available items as floating bubbles */ -import { useCallback, useState } from 'react'; +import { useCallback, useState, useMemo } from 'react'; import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion'; import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction'; import { BlobbiCompanion } from './BlobbiCompanion'; import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig'; import { calculateGroundY } from '../utils/movement'; +import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction'; +import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions'; +import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; import { useCompanionActionMenu, useBlobbiActions, @@ -211,6 +214,37 @@ export function BlobbiCompanionLayer() { closeMenu(); }, [closeMenu]); + // Status-based emotion reactions for the companion + // Uses the same two-layer system as the main BlobbiPage + const isSleeping = companion?.state === 'sleeping'; + const companionStats = useMemo(() => companion?.stats ?? { + hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100, + }, [companion?.stats]); + + const [companionActionOverride, setCompanionActionOverride] = useState(null); + + const { baseEmotion: companionBaseEmotion, overlayEmotion: companionOverlayEmotion } = useStatusReaction({ + stats: companionStats, + enabled: isVisible && !isSleeping && companion?.stage !== 'egg', + actionOverride: companionActionOverride, + }); + + // Set action override when using items on companion + // (This is triggered by handleItemUse above - we wrap it to add emotion) + const originalHandleItemUse = handleItemUse; + const handleItemUseWithEmotion = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => { + const action = CATEGORY_TO_ACTION[item.category] as ActionType | undefined; + if (action) { + setCompanionActionOverride(getActionEmotion(action)); + setTimeout(() => setCompanionActionOverride(null), 1500); + } + return originalHandleItemUse(item); + }, [originalHandleItemUse]); + + // Compute the emotion prop: overlay if present, otherwise base + const companionEmotionProp = companionOverlayEmotion ?? companionBaseEmotion; + const companionBaseEmotionProp = companionBaseEmotion !== 'neutral' ? companionBaseEmotion : undefined; + // Don't render anything if not visible if (!isVisible || !companion) { return null; @@ -232,6 +266,8 @@ export function BlobbiCompanionLayer() { onUpdateDrag: updateDrag, onEndDrag: endDrag, onClick: handleCompanionClick, + baseEmotion: companionBaseEmotionProp, + emotion: companionEmotionProp, onPositionUpdate: handlePositionUpdate, }; @@ -354,7 +390,7 @@ export function BlobbiCompanionLayer() { companionSize={config.size} onItemRelease={handleItemClick} onItemLanded={handleItemLanded} - onItemUse={handleItemUse} + onItemUse={handleItemUseWithEmotion} isItemOnCooldown={isItemOnCooldown} /> diff --git a/src/blobbi/companion/components/BlobbiCompanionVisual.tsx b/src/blobbi/companion/components/BlobbiCompanionVisual.tsx index b9fed2d0..83f83e58 100644 --- a/src/blobbi/companion/components/BlobbiCompanionVisual.tsx +++ b/src/blobbi/companion/components/BlobbiCompanionVisual.tsx @@ -11,6 +11,7 @@ import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual'; import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual'; import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters'; import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext'; +import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; import { cn } from '@/lib/utils'; import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types'; @@ -33,6 +34,10 @@ interface BlobbiCompanionVisualProps { isOnGround?: boolean; /** Distance from ground in pixels (for shadow fade, 0 = on ground) */ distanceFromGround?: number; + /** Base emotion for persistent face state (boring, dirty, dizzy, hungry) */ + baseEmotion?: BlobbiEmotion; + /** Overlay emotion (sleepy, action override, etc.) */ + emotion?: BlobbiEmotion; /** Additional class names */ className?: string; /** Debug mode - shows visual boundaries */ @@ -51,6 +56,8 @@ export function BlobbiCompanionVisual({ floatOffset = { x: 0, y: 0, rotation: 0 }, isOnGround = true, distanceFromGround = 0, + baseEmotion: baseEmotionProp, + emotion: emotionProp, className, debugMode = false, }: BlobbiCompanionVisualProps) { @@ -58,8 +65,13 @@ export function BlobbiCompanionVisual({ const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]); - // DEV ONLY: Get effective emotion from dev context - const effectiveEmotion = useEffectiveEmotion(); + // DEV ONLY: Get effective emotion from dev context (overrides production emotions) + const devEmotion = useEffectiveEmotion(); + const hasDevOverride = devEmotion !== 'neutral'; + + // Final emotions: dev override > props from status reaction system + const effectiveBaseEmotion = hasDevOverride ? undefined : baseEmotionProp; + const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral'); // Eye offset is now passed directly to the visual components via externalEyeOffset prop // This is more reliable than DOM manipulation which can be overwritten by useBlobbiEyes @@ -206,6 +218,7 @@ export function BlobbiCompanionVisual({ lookMode="forward" externalEyeOffset={eyeOffset} emotion={effectiveEmotion} + baseEmotion={effectiveBaseEmotion} className="size-full" /> )} @@ -216,6 +229,7 @@ export function BlobbiCompanionVisual({ lookMode="forward" externalEyeOffset={eyeOffset} emotion={effectiveEmotion} + baseEmotion={effectiveBaseEmotion} className="size-full" /> )} diff --git a/src/blobbi/companion/hooks/useBlobbiCompanionData.ts b/src/blobbi/companion/hooks/useBlobbiCompanionData.ts index ff337136..a4cfd61d 100644 --- a/src/blobbi/companion/hooks/useBlobbiCompanionData.ts +++ b/src/blobbi/companion/hooks/useBlobbiCompanionData.ts @@ -100,6 +100,14 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult { stage: blobbi.stage, visualTraits: blobbi.visualTraits, energy: blobbi.stats.energy ?? 100, + stats: { + hunger: blobbi.stats.hunger ?? 100, + happiness: blobbi.stats.happiness ?? 100, + health: blobbi.stats.health ?? 100, + hygiene: blobbi.stats.hygiene ?? 100, + energy: blobbi.stats.energy ?? 100, + }, + state: blobbi.state, // Include adult form info for proper rendering adultType: blobbi.adultType, seed: blobbi.seed, diff --git a/src/blobbi/companion/types/companion.types.ts b/src/blobbi/companion/types/companion.types.ts index d975acd2..10114b18 100644 --- a/src/blobbi/companion/types/companion.types.ts +++ b/src/blobbi/companion/types/companion.types.ts @@ -5,7 +5,7 @@ * decoupled from app-specific concerns. */ -import type { BlobbiVisualTraits } from '@/blobbi/core/lib/blobbi'; +import type { BlobbiVisualTraits, BlobbiStats } from '@/blobbi/core/lib/blobbi'; // ─── Companion State Machine ────────────────────────────────────────────────── @@ -176,6 +176,10 @@ export interface CompanionData { visualTraits: BlobbiVisualTraits; /** Current energy level (0-100) - affects walking speed */ energy: number; + /** Current stats for status-based visual reactions */ + stats: BlobbiStats; + /** Current companion state (e.g., 'sleeping') */ + state?: string; /** Adult evolution form type (e.g., 'catti', 'pupp', 'buni') - only for adults */ adultType?: string; /** Deterministic seed for deriving traits */ diff --git a/src/blobbi/ui/BlobbiStageVisual.tsx b/src/blobbi/ui/BlobbiStageVisual.tsx index 496c20cb..9ea879c4 100644 --- a/src/blobbi/ui/BlobbiStageVisual.tsx +++ b/src/blobbi/ui/BlobbiStageVisual.tsx @@ -47,11 +47,17 @@ export interface BlobbiStageVisualProps { /** Disable blinking animation (for photo/export mode) */ disableBlink?: boolean; /** - * Emotional state to display. - * Adds visual overlays like eyebrows, modified mouth, and tears. + * Emotional state to display (overlay emotion). + * When used with baseEmotion, this is applied on top of the base. * Default: 'neutral' (no modifications) */ emotion?: BlobbiEmotion; + /** + * Base emotion for persistent face state (boring, dirty, dizzy, hungry). + * Applied first, then the overlay emotion animates on top. + * Example: baseEmotion='boring', emotion='sleepy' → boring face with sleepy animation + */ + baseEmotion?: BlobbiEmotion; /** Additional CSS classes for the container */ className?: string; } @@ -88,6 +94,7 @@ export function BlobbiStageVisual({ lookMode = 'follow-pointer', disableBlink = false, emotion = 'neutral', + baseEmotion, className, }: BlobbiStageVisualProps) { const { stage } = companion; @@ -134,6 +141,7 @@ export function BlobbiStageVisual({ lookMode={lookMode} disableBlink={disableBlink} emotion={emotion} + baseEmotion={baseEmotion} className="size-full" /> @@ -151,6 +159,7 @@ export function BlobbiStageVisual({ lookMode={lookMode} disableBlink={disableBlink} emotion={emotion} + baseEmotion={baseEmotion} className="size-full" /> diff --git a/src/blobbi/ui/hooks/useStatusReaction.ts b/src/blobbi/ui/hooks/useStatusReaction.ts index 6523efb9..837dac95 100644 --- a/src/blobbi/ui/hooks/useStatusReaction.ts +++ b/src/blobbi/ui/hooks/useStatusReaction.ts @@ -1,37 +1,41 @@ /** * useStatusReaction Hook * - * Manages automatic status-based reactions for Blobbi. - * Handles timing, cooldowns, and state transitions for a natural-feeling reaction system. + * Manages automatic status-based reactions for Blobbi using a two-layer emotion model: + * 1. **Base emotion**: Persistent face state (boring, dirty, dizzy, hungry) + * 2. **Overlay emotion**: Temporary animation on top (sleepy) + * + * The hook uses `resolveStatusEmotions()` as the single source of truth for + * mapping stats → emotions. Both layers are tracked independently: + * - Base emotions persist while the triggering stat remains below threshold + * - Overlay emotions (sleepy) persist independently of the base * * Features: * - Periodic stat checks with configurable intervals - * - Persistent reactions for critical/continuous states (sleepy, crying, dizzy, hungry) - * - One-shot reactions for temporary expressions + * - Independent persistence for base and overlay emotions * - Animation-aware state transitions (won't interrupt mid-animation) - * - Override support for temporary action reactions + * - Override support for temporary action reactions (overrides overlay only) * - Clean state management with proper cleanup * * Key Design Principles: - * - Track the currently active reaction to avoid unnecessary restarts - * - Distinguish between persistent (looping) and one-shot (timed) reactions - * - Only replace the current reaction when: - * 1. The reaction type actually changed, or - * 2. A higher-priority reaction must interrupt, or - * 3. The current reaction is non-persistent and its cycle completed - * - Preserve animation continuity by not resetting on stats recomputation + * - Base and overlay emotions are resolved and tracked independently + * - `resolveStatusEmotions()` determines what SHOULD be showing + * - The hook manages WHEN transitions happen (animation safety, cooldowns) + * - Action overrides replace the overlay emotion, base persists underneath */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import type { BlobbiEmotion } from '../lib/emotions'; import type { BlobbiStats } from '@/blobbi/core/types/blobbi'; import { - resolveStatusReaction, + resolveStatusEmotions, getDefaultEmotion, DEFAULT_TIMING, + SEVERITY_THRESHOLDS, type StatusReactionTiming, type ReactiveStat, type StatSeverity, + type StatusEmotionResult, } from '../lib/status-reactions'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -48,44 +52,23 @@ export interface UseStatusReactionOptions { } export interface StatusReactionState { - /** Current emotion to display */ - currentEmotion: BlobbiEmotion; - /** Whether a status reaction is actively showing */ + /** Base emotion (persistent face: boring, dirty, dizzy, hungry) */ + baseEmotion: BlobbiEmotion; + /** Overlay emotion (animation on top: sleepy, or action override) */ + overlayEmotion: BlobbiEmotion | null; + /** Whether any status reaction is actively showing (base or overlay) */ isStatusReactionActive: boolean; - /** The stat that triggered the current reaction (if any) */ - triggeringStat: ReactiveStat | null; - /** Severity of the current reaction */ + /** The stat that triggered the base emotion (if any) */ + triggeringBaseStat: ReactiveStat | null; + /** The stat that triggered the overlay emotion (if any) */ + triggeringOverlayStat: ReactiveStat | null; + /** Severity of the highest-priority active reaction */ currentSeverity: StatSeverity | null; - /** Whether an action override is active */ + /** Whether an action override is active (replaces overlay) */ isOverrideActive: boolean; } -// ─── Reaction Persistence Configuration ─────────────────────────────────────── - -/** - * Reactions that should persist (loop continuously) while their triggering - * condition remains active. These are NOT timed out - they only end when - * the stat recovers or a higher priority reaction takes over. - * - * Persistent reactions are typically associated with critical or high severity - * states where the visual should remain as long as the condition persists. - */ -const PERSISTENT_REACTIONS: Set = new Set([ - '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 -]); - -/** - * Check if a reaction type is persistent (loops until condition clears) - * vs one-shot (plays once then returns to default). - */ -function isPersistentReaction(emotion: BlobbiEmotion): boolean { - return PERSISTENT_REACTIONS.has(emotion); -} +// ─── Emotion Cycle Durations ────────────────────────────────────────────────── /** * Minimum animation cycle durations for each emotion. @@ -97,6 +80,8 @@ const EMOTION_CYCLE_DURATIONS: Partial> = { sad: 6000, // 6s tear cycle (matches tears.duration) dizzy: 2000, // 2s rotation (matches dizzyEffect.rotationDuration) hungry: 4000, // Drool/icon animation cycle + boring: 3000, // Boring expression settle time + dirty: 3000, // Dirt/stink cloud cycle angry: 2000, // Anger rise animation surprised: 1000, // Brief expression curious: 1000, // Brief expression @@ -118,36 +103,37 @@ function getEmotionCycleDuration(emotion: BlobbiEmotion): number { interface InternalState { /** Timer for the next stat check */ checkTimer: ReturnType | null; - /** Timer for one-shot reaction duration (when to return to default) */ - durationTimer: ReturnType | null; - /** Timestamp when the current reaction was started */ - reactionStartTime: number; - /** Timestamp of last stat check that triggered a reaction change */ - lastTriggerTime: number; - /** Whether we're in cooldown period after a one-shot reaction */ - inCooldown: boolean; - /** The currently active emotion (to detect actual changes) */ - currentActiveEmotion: BlobbiEmotion | null; - /** The stat that triggered the current reaction */ - currentTriggeringStat: ReactiveStat | null; - /** Whether the current reaction is persistent */ - isCurrentPersistent: boolean; + /** Timestamp when the current base emotion was set */ + baseStartTime: number; + /** Timestamp when the current overlay emotion was set */ + overlayStartTime: number; + /** Currently active base emotion (to detect changes) */ + currentBase: BlobbiEmotion | null; + /** Currently active overlay emotion (to detect changes) */ + currentOverlay: BlobbiEmotion | null; + /** The stat triggering the current base */ + currentBaseStat: ReactiveStat | null; + /** The stat triggering the current overlay */ + currentOverlayStat: ReactiveStat | null; } // ─── Hook ───────────────────────────────────────────────────────────────────── /** - * Hook for managing automatic status-based reactions. + * Hook for managing automatic status-based reactions with two-layer emotions. * * @example * ```tsx - * const { currentEmotion } = useStatusReaction({ + * const { baseEmotion, overlayEmotion } = useStatusReaction({ * stats: companion.stats, * enabled: !isSleeping, * actionOverride: activeActionEmotion, * }); * - * + * * ``` */ export function useStatusReaction({ @@ -166,27 +152,23 @@ export function useStatusReaction({ }, }), [timingOverride]); - // State for the current status reaction - const [statusReaction, setStatusReaction] = useState<{ - emotion: BlobbiEmotion | null; - stat: ReactiveStat | null; - severity: StatSeverity | null; - }>({ - emotion: null, - stat: null, - severity: null, + // State: resolved base and overlay emotions from status + const [statusEmotions, setStatusEmotions] = useState({ + baseEmotion: null, + overlayEmotion: null, + triggeringBaseStat: null, + triggeringOverlayStat: null, }); // Refs for timers and internal state (don't cause re-renders) const internalRef = useRef({ checkTimer: null, - durationTimer: null, - reactionStartTime: 0, - lastTriggerTime: 0, - inCooldown: false, - currentActiveEmotion: null, - currentTriggeringStat: null, - isCurrentPersistent: false, + baseStartTime: 0, + overlayStartTime: 0, + currentBase: null, + currentOverlay: null, + currentBaseStat: null, + currentOverlayStat: null, }); // Stable reference to stats for use in callbacks @@ -204,210 +186,203 @@ export function useStatusReaction({ clearTimeout(internal.checkTimer); internal.checkTimer = null; } - if (internal.durationTimer) { - clearTimeout(internal.durationTimer); - internal.durationTimer = null; - } }, []); - // Clear the current status reaction (return to default) - const clearStatusReaction = useCallback(() => { + // Clear all status emotions (return to default) + const clearStatusEmotions = useCallback(() => { const internal = internalRef.current; + internal.currentBase = null; + internal.currentOverlay = null; + internal.currentBaseStat = null; + internal.currentOverlayStat = null; - // Clear duration timer if exists - if (internal.durationTimer) { - clearTimeout(internal.durationTimer); - internal.durationTimer = null; - } - - // Reset internal tracking - internal.currentActiveEmotion = null; - internal.currentTriggeringStat = null; - internal.isCurrentPersistent = false; - - setStatusReaction({ - emotion: null, - stat: null, - severity: null, + setStatusEmotions({ + baseEmotion: null, + overlayEmotion: null, + triggeringBaseStat: null, + triggeringOverlayStat: null, }); }, []); /** - * Activate a reaction. This is the core function that decides whether - * to actually change the displayed emotion or keep the current one. + * Apply resolved emotions, respecting animation safety. * - * Key behavior: - * - If the same emotion is already active, do NOT restart it - * - If a persistent reaction is active, only replace with higher priority - * - If a one-shot reaction is mid-animation, wait for cycle to complete + * Each layer (base, overlay) transitions independently: + * - If the same emotion is already active on that layer, don't restart + * - If a different emotion needs to take over, check animation cycle safety + * - Each layer can change without affecting the other */ - const activateReaction = useCallback(( - emotion: BlobbiEmotion, - stat: ReactiveStat, - severity: StatSeverity, - priority: number - ) => { + const applyEmotions = useCallback((resolved: StatusEmotionResult) => { const internal = internalRef.current; const now = Date.now(); - - // Check if this is the same reaction that's already active - if (internal.currentActiveEmotion === emotion && - internal.currentTriggeringStat === stat) { - // Same reaction already active - DO NOT restart - // Just update the timestamp to keep it alive if persistent - if (internal.isCurrentPersistent) { - internal.lastTriggerTime = now; - } - return; - } - - // Check if we should interrupt the current reaction - if (internal.currentActiveEmotion !== null) { - const timeSinceStart = now - internal.reactionStartTime; - const currentCycleDuration = getEmotionCycleDuration(internal.currentActiveEmotion); - - // If current reaction is persistent and new reaction has same/lower priority, - // don't interrupt unless current condition has cleared - if (internal.isCurrentPersistent) { - // For persistent reactions, we need to check if the current triggering stat - // has recovered (severity went back to normal) - const currentStat = internal.currentTriggeringStat; - if (currentStat) { - const currentStatValue = statsRef.current[currentStat]; - // If the current stat is still below warning threshold, keep the current reaction - // unless the new reaction is higher priority (lower number) - const currentStillActive = currentStatValue < 70; // warning threshold - - if (currentStillActive) { - // Current persistent reaction should continue - // Only allow interruption by higher priority reactions - const currentPriority = getStatPriority(currentStat); - if (priority >= currentPriority) { - // Same or lower priority - don't interrupt - return; - } - // Higher priority - allow interruption + + let baseChanged = false; + let overlayChanged = false; + + // ── Base emotion transition ── + if (resolved.baseEmotion !== internal.currentBase) { + if (resolved.baseEmotion === null) { + // Base condition cleared — check if the triggering stat actually recovered + if (internal.currentBaseStat) { + const statValue = statsRef.current[internal.currentBaseStat]; + if (statValue >= SEVERITY_THRESHOLDS.warning) { + // Stat recovered, clear base + internal.currentBase = null; + internal.currentBaseStat = null; + baseChanged = true; } + // Otherwise keep current base (stat hasn't actually recovered) + } else { + internal.currentBase = null; + internal.currentBaseStat = null; + baseChanged = true; } + } else if (internal.currentBase === null) { + // No current base, activate immediately + internal.currentBase = resolved.baseEmotion; + internal.currentBaseStat = resolved.triggeringBaseStat; + internal.baseStartTime = now; + baseChanged = true; } else { - // For one-shot reactions, check if we're mid-animation - if (timeSinceStart < currentCycleDuration) { - // Mid-animation - only interrupt for critical severity - if (severity !== 'critical') { - return; + // Switching base emotions — check animation safety + const elapsed = now - internal.baseStartTime; + const cycleDuration = getEmotionCycleDuration(internal.currentBase); + if (elapsed >= cycleDuration) { + internal.currentBase = resolved.baseEmotion; + internal.currentBaseStat = resolved.triggeringBaseStat; + internal.baseStartTime = now; + baseChanged = true; + } + // else: mid-animation, wait for next check + } + } + + // ── Overlay emotion transition ── + if (resolved.overlayEmotion !== internal.currentOverlay) { + if (resolved.overlayEmotion === null) { + // Overlay condition cleared — check stat recovery + if (internal.currentOverlayStat) { + const statValue = statsRef.current[internal.currentOverlayStat]; + if (statValue >= SEVERITY_THRESHOLDS.warning) { + internal.currentOverlay = null; + internal.currentOverlayStat = null; + overlayChanged = true; } + } else { + internal.currentOverlay = null; + internal.currentOverlayStat = null; + overlayChanged = true; + } + } else if (internal.currentOverlay === null) { + // No current overlay, activate immediately + internal.currentOverlay = resolved.overlayEmotion; + internal.currentOverlayStat = resolved.triggeringOverlayStat; + internal.overlayStartTime = now; + overlayChanged = true; + } else { + // Switching overlay emotions — check animation safety + const elapsed = now - internal.overlayStartTime; + const cycleDuration = getEmotionCycleDuration(internal.currentOverlay); + if (elapsed >= cycleDuration) { + internal.currentOverlay = resolved.overlayEmotion; + internal.currentOverlayStat = resolved.triggeringOverlayStat; + internal.overlayStartTime = now; + overlayChanged = true; } } } - // Clear any existing duration timer - if (internal.durationTimer) { - clearTimeout(internal.durationTimer); - internal.durationTimer = null; + // Only trigger a re-render if something actually changed + if (baseChanged || overlayChanged) { + setStatusEmotions({ + baseEmotion: internal.currentBase, + overlayEmotion: internal.currentOverlay, + triggeringBaseStat: internal.currentBaseStat, + triggeringOverlayStat: internal.currentOverlayStat, + }); } - - // Update internal tracking - internal.currentActiveEmotion = emotion; - internal.currentTriggeringStat = stat; - internal.isCurrentPersistent = isPersistentReaction(emotion); - internal.reactionStartTime = now; - internal.lastTriggerTime = now; - internal.inCooldown = false; - - // Set the reaction state - setStatusReaction({ emotion, stat, severity }); - - // For NON-persistent reactions, set a timer to clear after duration - if (!internal.isCurrentPersistent) { - internal.durationTimer = setTimeout(() => { - clearStatusReaction(); - internal.inCooldown = true; - }, timingRef.current.reactionDuration); - } - }, [clearStatusReaction]); + }, []); /** - * Check stats and potentially trigger/maintain a reaction. - * This is called periodically and handles all state transitions. + * Check stats and update emotions. + * Called periodically by the check loop. */ const checkStats = useCallback(() => { - const internal = internalRef.current; const currentStats = statsRef.current; const currentTiming = timingRef.current; - - const result = resolveStatusReaction(currentStats, false, currentTiming); + const internal = internalRef.current; - if (result.shouldTrigger && result.emotion && result.triggeringStat && result.severity) { - // Check cooldown for non-persistent reactions - const timeSinceLastTrigger = Date.now() - internal.lastTriggerTime; - const currentCooldown = internal.inCooldown ? result.cooldownMs : 0; - - if (timeSinceLastTrigger >= currentCooldown) { - const priority = getStatPriority(result.triggeringStat); - activateReaction(result.emotion, result.triggeringStat, result.severity, priority); - } - } else if (internal.isCurrentPersistent && internal.currentTriggeringStat) { - // No new reaction to trigger - check if current persistent reaction should end - // This happens when the triggering stat has recovered - const currentStatValue = currentStats[internal.currentTriggeringStat]; - if (currentStatValue >= 70) { // warning threshold - condition cleared - clearStatusReaction(); - } - } + const resolved = resolveStatusEmotions(currentStats); + applyEmotions(resolved); // Schedule next check internal.checkTimer = setTimeout(checkStats, currentTiming.checkInterval); - }, [activateReaction, clearStatusReaction]); + }, [applyEmotions]); // Start/stop the check loop based on enabled state - // IMPORTANT: This effect only depends on `enabled`, not `stats` - // Stats changes are handled by the periodic checkStats loop useEffect(() => { if (!enabled) { clearTimers(); - clearStatusReaction(); + clearStatusEmotions(); return; } - // Do an initial check to set up the reaction state - const initialResult = resolveStatusReaction(statsRef.current, true, timingRef.current); - if (initialResult.shouldTrigger && initialResult.emotion && - initialResult.triggeringStat && initialResult.severity) { - const priority = getStatPriority(initialResult.triggeringStat); - activateReaction( - initialResult.emotion, - initialResult.triggeringStat, - initialResult.severity, - priority - ); - } + // Initial check — apply immediately + const initialResolved = resolveStatusEmotions(statsRef.current); + // For initial check, apply directly without animation safety + const internal = internalRef.current; + internal.currentBase = initialResolved.baseEmotion; + internal.currentBaseStat = initialResolved.triggeringBaseStat; + internal.baseStartTime = Date.now(); + internal.currentOverlay = initialResolved.overlayEmotion; + internal.currentOverlayStat = initialResolved.triggeringOverlayStat; + internal.overlayStartTime = Date.now(); - // Start the check loop - internalRef.current.checkTimer = setTimeout(checkStats, timingRef.current.checkInterval); + setStatusEmotions(initialResolved); + + // Start the periodic check loop + internal.checkTimer = setTimeout(checkStats, timingRef.current.checkInterval); return () => { clearTimers(); }; - }, [enabled, checkStats, clearTimers, clearStatusReaction, activateReaction]); + }, [enabled, checkStats, clearTimers, clearStatusEmotions]); - // Handle stats changes for persistent reactions - // This effect watches for stat changes that might clear a persistent reaction - // but does NOT restart reactions - it only allows clearing when conditions improve + // Watch for stat recovery on persistent emotions + // This allows faster clearing when stats improve (don't wait for next check interval) useEffect(() => { const internal = internalRef.current; - - // Only care about this if we have an active persistent reaction - if (!internal.isCurrentPersistent || !internal.currentTriggeringStat) { - return; + let changed = false; + + // Check if base emotion's triggering stat has recovered + if (internal.currentBase && internal.currentBaseStat) { + const statValue = stats[internal.currentBaseStat]; + if (statValue >= SEVERITY_THRESHOLDS.warning) { + internal.currentBase = null; + internal.currentBaseStat = null; + changed = true; + } } - - // Check if the triggering stat has recovered - const currentStatValue = stats[internal.currentTriggeringStat]; - if (currentStatValue >= 70) { // warning threshold - // Condition cleared - end the persistent reaction - clearStatusReaction(); + + // Check if overlay emotion's triggering stat has recovered + if (internal.currentOverlay && internal.currentOverlayStat) { + const statValue = stats[internal.currentOverlayStat]; + if (statValue >= SEVERITY_THRESHOLDS.warning) { + internal.currentOverlay = null; + internal.currentOverlayStat = null; + changed = true; + } } - }, [stats, clearStatusReaction]); + + if (changed) { + setStatusEmotions({ + baseEmotion: internal.currentBase, + overlayEmotion: internal.currentOverlay, + triggeringBaseStat: internal.currentBaseStat, + triggeringOverlayStat: internal.currentOverlayStat, + }); + } + }, [stats]); // Clean up on unmount useEffect(() => { @@ -416,25 +391,32 @@ export function useStatusReaction({ }; }, [clearTimers]); - // Determine the final emotion to display - // Priority: actionOverride > statusReaction > default + // ── Determine final output ── + // Action override replaces the overlay, base emotion persists underneath const isOverrideActive = actionOverride !== null && actionOverride !== undefined; - const isStatusReactionActive = statusReaction.emotion !== null && !isOverrideActive; - let currentEmotion: BlobbiEmotion; - if (isOverrideActive) { - currentEmotion = actionOverride; - } else if (statusReaction.emotion) { - currentEmotion = statusReaction.emotion; - } else { - currentEmotion = getDefaultEmotion(); - } + const resolvedBase = statusEmotions.baseEmotion ?? getDefaultEmotion(); + const resolvedOverlay = isOverrideActive + ? actionOverride // action override becomes the overlay + : statusEmotions.overlayEmotion; + + const isStatusReactionActive = statusEmotions.baseEmotion !== null || + (statusEmotions.overlayEmotion !== null && !isOverrideActive); + + // Determine severity from the highest-priority active stat + const currentSeverity = statusEmotions.triggeringBaseStat + ? getSeverityFromStats(stats, statusEmotions.triggeringBaseStat) + : statusEmotions.triggeringOverlayStat + ? getSeverityFromStats(stats, statusEmotions.triggeringOverlayStat) + : null; return { - currentEmotion, + baseEmotion: resolvedBase, + overlayEmotion: resolvedOverlay, isStatusReactionActive, - triggeringStat: isStatusReactionActive ? statusReaction.stat : null, - currentSeverity: isStatusReactionActive ? statusReaction.severity : null, + triggeringBaseStat: statusEmotions.triggeringBaseStat, + triggeringOverlayStat: isOverrideActive ? null : statusEmotions.triggeringOverlayStat, + currentSeverity, isOverrideActive, }; } @@ -442,16 +424,12 @@ export function useStatusReaction({ // ─── Helper Functions ───────────────────────────────────────────────────────── /** - * Get the priority number for a stat (lower = higher priority). - * Matches the priority values in STAT_REACTION_CONFIGS. + * Get severity for a specific stat from the current stats. */ -function getStatPriority(stat: ReactiveStat): number { - const priorityMap: Record = { - energy: 1, - health: 2, - hunger: 3, - hygiene: 4, - happiness: 5, - }; - return priorityMap[stat] ?? 99; +function getSeverityFromStats(stats: BlobbiStats, stat: ReactiveStat): StatSeverity { + const value = stats[stat]; + if (value < SEVERITY_THRESHOLDS.critical) return 'critical'; + if (value < SEVERITY_THRESHOLDS.high) return 'high'; + if (value < SEVERITY_THRESHOLDS.warning) return 'warning'; + return 'normal'; } diff --git a/src/blobbi/ui/lib/status-reactions.ts b/src/blobbi/ui/lib/status-reactions.ts index 25f4eccf..81f9f16d 100644 --- a/src/blobbi/ui/lib/status-reactions.ts +++ b/src/blobbi/ui/lib/status-reactions.ts @@ -259,6 +259,10 @@ export function analyzeAllStats(stats: BlobbiStats): StatAnalysis[] { * Resolve the best reaction to show based on current stats. * Uses priority-based selection with probabilistic triggering. * + * @deprecated Use `resolveStatusEmotions()` instead, which properly separates + * base and overlay emotions. This function flattens everything into a single + * emotion and is kept only for legacy compatibility. + * * @param stats - Current Blobbi stats * @param forceCheck - If true, bypasses probability check (useful for initial state) * @param timing - Timing configuration @@ -358,32 +362,6 @@ export function resolveStatusEmotions(stats: BlobbiStats): StatusEmotionResult { }; } -/** - * 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 ─────────────────────────────────────────────────── /** diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index e2c2bdda..378aa6e5 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -878,16 +878,20 @@ function BlobbiDashboard({ energy: projectedState?.stats.energy ?? companion.stats.energy ?? 100, }), [projectedState, companion.stats]); - const { currentEmotion: statusEmotion } = useStatusReaction({ + const { baseEmotion: statusBaseEmotion, overlayEmotion: statusOverlayEmotion } = useStatusReaction({ stats: currentStats, enabled: !isSleeping && !isEgg, // Disable when sleeping or egg stage actionOverride: actionOverrideEmotion, }); - // Final emotion: dev override > status reaction system (which handles action override internally) - const effectiveEmotion: BlobbiEmotion = isLocalhostDev() && devEmotionOverride !== 'neutral' - ? devEmotionOverride - : statusEmotion; + // Final emotions: dev override > status reaction system + // Dev override replaces the overlay for visual testing; base persists underneath + const hasDevOverride = isLocalhostDev() && devEmotionOverride !== 'neutral'; + const effectiveBaseEmotion: BlobbiEmotion = hasDevOverride ? 'neutral' : statusBaseEmotion; + const effectiveOverlayEmotion: BlobbiEmotion | null = hasDevOverride ? devEmotionOverride : statusOverlayEmotion; + + // The emotion prop passed to the visual: overlay if present, otherwise base + const effectiveEmotion: BlobbiEmotion = effectiveOverlayEmotion ?? effectiveBaseEmotion; // Adoption flow modal state const [showAdoptionFlow, setShowAdoptionFlow] = useState(false); @@ -1425,6 +1429,7 @@ function BlobbiDashboard({ animated={!isSleeping} reaction={blobbiReaction} emotion={effectiveEmotion} + baseEmotion={effectiveBaseEmotion !== 'neutral' ? effectiveBaseEmotion : undefined} className="size-48 sm:size-56" />