Complete baseEmotion + overlayEmotion migration across the visual pipeline

Finish the two-layer emotion architecture so resolveStatusEmotions() is
the single source of truth and both the main BlobbiPage and the floating
companion use the same flow.

useStatusReaction:
- Now returns baseEmotion, overlayEmotion, triggeringBaseStat,
  triggeringOverlayStat, isStatusReactionActive, currentSeverity,
  isOverrideActive (replaces the old single currentEmotion).
- Internally calls resolveStatusEmotions() on every check cycle and
  tracks base and overlay transitions independently with animation
  safety per layer.
- Action overrides replace the overlay; the base persists underneath.

status-reactions.ts:
- Remove combineEmotions() (no longer needed).
- Deprecate resolveStatusReaction() with a JSDoc notice.
- resolveStatusEmotions() is now the primary API.

BlobbiStageVisual:
- Accepts a new baseEmotion prop and forwards it to BlobbiBabyVisual
  and BlobbiAdultVisual.

BlobbiPage (main consumer):
- Destructures baseEmotion + overlayEmotion from the hook and passes
  both through to BlobbiStageVisual.

Companion system:
- CompanionData now carries full BlobbiStats and state.
- BlobbiCompanionLayer runs its own useStatusReaction to drive the
  companion's emotions from stats, including item-use action overrides.
- BlobbiCompanion and BlobbiCompanionVisual accept baseEmotion + emotion
  props and forward them to the underlying visual components.
This commit is contained in:
filemon
2026-03-29 17:27:17 -03:00
parent a3e6ff34db
commit 8d04bbbdbe
9 changed files with 338 additions and 297 deletions
@@ -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}
/>
</div>
@@ -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<BlobbiEmotion | null>(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}
/>
</div>
@@ -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"
/>
)}
@@ -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,
@@ -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 */
+11 -2
View File
@@ -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"
/>
<FloatingMusicNotes active={showMusicNotes} />
@@ -151,6 +159,7 @@ export function BlobbiStageVisual({
lookMode={lookMode}
disableBlink={disableBlink}
emotion={emotion}
baseEmotion={baseEmotion}
className="size-full"
/>
<FloatingMusicNotes active={showMusicNotes} />
+237 -259
View File
@@ -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<BlobbiEmotion> = 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<Record<BlobbiEmotion, number>> = {
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<typeof setTimeout> | null;
/** Timer for one-shot reaction duration (when to return to default) */
durationTimer: ReturnType<typeof setTimeout> | 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,
* });
*
* <BlobbiStageVisual emotion={currentEmotion} />
* <BlobbiStageVisual
* baseEmotion={baseEmotion}
* emotion={overlayEmotion ?? baseEmotion}
* />
* ```
*/
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<StatusEmotionResult>({
baseEmotion: null,
overlayEmotion: null,
triggeringBaseStat: null,
triggeringOverlayStat: null,
});
// Refs for timers and internal state (don't cause re-renders)
const internalRef = useRef<InternalState>({
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<ReactiveStat, number> = {
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';
}
+4 -26
View File
@@ -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 ───────────────────────────────────────────────────
/**
+10 -5
View File
@@ -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"
/>
</div>