Add shake-to-dizzy reaction with nausea fill and fix SMIL animation stability

Shake reaction system
---------------------
Introduce a reusable shake detection + reaction pipeline that triggers
dizzy visuals when Blobbi is shaken during drag, with progressive green
nausea body fill when hunger is high (>= 90, currently debug-bypassed).

Architecture follows the same phase/level/profile pattern as click
overstimulation for future extensibility (personality variants,
additional physical-stress reactions).

New files:
- shakeDetection.ts: pure motion sampling (direction reversals,
  speed accumulation, energy integral)
- useShakeReaction.ts: 4-phase state machine (idle → shaking → dizzy
  → recovering), profile system, recipe resolution with nausea fill

Phases:
- idle:       no shake reaction active
- shaking:    user actively shaking (dizzy face + live green fill rise)
- dizzy:      post-release hold (3-8s scaled by intensity, fill drains)
- recovering: nausea draining via rAF, then back to idle

SMIL animation stability fix
-----------------------------
The nausea fill level changes ~12×/sec during drain, creating a new
recipe object each tick. This broke the React.memo barrier on
MemoizedBlobbiVisual (reference equality), triggering full SVG DOM
replacement via dangerouslySetInnerHTML — killing all SMIL animations
(dizzy spirals, sleepy blinks) on every update.

Fix: both SvgRenderers now compute a structural recipe fingerprint
that clones the recipe and strips only bodyEffects.angerRise.level.
The customizedSvg useMemo depends on this string (compared by value),
so level-only changes skip the SVG rebuild. The fill level is applied
imperatively via gradient stop setAttribute() in a separate useEffect,
preserving the existing DOM and all running SMIL animations.

Reaction retrigger fix
----------------------
Both shake and overstimulation reactions had cycle-scoped refs that
were not cleaned up when the reaction drained to idle via the natural
rAF path, preventing immediate retrigger:
- Overstimulation: clicksRef (stale timestamps) now cleared at idle
- Shake: toastShownRef now reset at idle

Body fill improvements
----------------------
- angerRise generator now accepts caller-controlled bottomOpacity and
  edgeOpacity so nausea (strong green) and anger (moderate red) can
  have different visual intensity through the same shared generator
- Static-level fill mode uses real body bounds from path detection
  instead of hardcoded coordinates
- Nausea fill drains during the dizzy hold (not only after it ends)
  for a smooth continuous descent

Temporary debug bypass still active:
  true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD
Must be removed before final merge.
This commit is contained in:
filemon
2026-04-18 04:58:10 -03:00
parent c2af41c7f2
commit 91de4f80d8
12 changed files with 869 additions and 27 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.10.0",
"version": "2.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.10.0",
"version": "2.10.1",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -79,6 +79,12 @@ interface BlobbiCompanionProps {
onPositionUpdate?: (position: Position) => void;
/** Debug mode - disables animations and shows visual debug aids */
debugMode?: boolean;
/**
* Called on every pointer move during an active drag with the raw
* pointer position. Used by the shake detection system to sample
* motion intensity without coupling this component to the tracker.
*/
onDragSample?: (position: Position) => void;
}
export function BlobbiCompanion({
@@ -103,6 +109,7 @@ export function BlobbiCompanion({
bodyEffects,
onPositionUpdate,
debugMode = false,
onDragSample,
}: BlobbiCompanionProps) {
const config = DEFAULT_COMPANION_CONFIG;
const containerRef = useRef<HTMLDivElement>(null);
@@ -259,8 +266,11 @@ export function BlobbiCompanion({
const newX = e.clientX - config.size / 2;
const newY = e.clientY - config.size / 2;
onUpdateDrag({ x: newX, y: newY });
// Feed raw pointer position to shake detection
onDragSample?.(position);
}
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag, onDragSample]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (containerRef.current) {
@@ -14,12 +14,14 @@
* This file should be placed at the app root level (renders a fixed overlay).
*/
import { useCallback, useState, useMemo } from 'react';
import { useCallback, useState, useMemo, useRef } from 'react';
import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion';
import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction';
import { useActionEmotionOverride } from '../hooks/useActionEmotionOverride';
import { useOverstimulationReaction } from '../hooks/useOverstimulationReaction';
import { useShakeReaction } from '../hooks/useShakeReaction';
import { createShakeTracker, recordSample, computeShakeResult, resetTracker } from '../core/shakeDetection';
import { BlobbiCompanion } from './BlobbiCompanion';
import { DebugGroundOverlay } from './DebugGroundOverlay';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
@@ -146,6 +148,46 @@ export function BlobbiCompanionLayer() {
isActive: isVisible && !isEntering,
});
// ── Shake reaction (dizzy / nausea) ───────────────────────────────────────
const shakeTrackerRef = useRef(createShakeTracker());
const companionHunger = companion?.stats.hunger ?? 100;
const {
recipe: shakeRecipe,
recipeLabel: shakeLabel,
onDragUpdate: shakeOnDragUpdate,
onDragEnd: shakeOnDragEnd,
onDragStart: shakeOnDragStart,
} = useShakeReaction({
isActive: isVisible && !isEntering,
hunger: companionHunger,
});
/** Feed pointer positions into the shake tracker during drag and
* push live shake results into the reaction hook each sample. */
const handleDragSample = useCallback((position: Position) => {
recordSample(shakeTrackerRef.current, position);
// Compute live result so the hook can react during the drag
const liveResult = computeShakeResult(shakeTrackerRef.current);
shakeOnDragUpdate(liveResult);
}, [shakeOnDragUpdate]);
/** Wrap startDrag to also notify the shake system. */
const handleStartDrag = useCallback(() => {
resetTracker(shakeTrackerRef.current);
shakeOnDragStart();
startDrag();
}, [startDrag, shakeOnDragStart]);
/** Wrap endDrag to compute shake result and notify the shake system. */
const handleEndDrag = useCallback(() => {
const result = computeShakeResult(shakeTrackerRef.current);
shakeOnDragEnd(result);
resetTracker(shakeTrackerRef.current);
endDrag();
}, [endDrag, shakeOnDragEnd]);
const handleItemUse = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => {
const action = CATEGORY_TO_ACTION[item.category];
@@ -250,8 +292,9 @@ export function BlobbiCompanionLayer() {
// Recipe priority chain (highest → lowest):
// 1. Sleeping (always wins when companion is asleep)
// 2. Overstimulation reaction (user spam-clicking)
// 3. Action override (item use: feed → happy, etc.)
// 4. Status recipe (stat-driven expressions)
// 3. Shake reaction (dizzy / nausea from shaking)
// 4. Action override (item use: feed → happy, etc.)
// 5. Status recipe (stat-driven expressions)
let companionRecipe: typeof statusRecipe;
let companionRecipeLabel: string;
@@ -261,6 +304,9 @@ export function BlobbiCompanionLayer() {
} else if (overstimRecipe && overstimLabel) {
companionRecipe = overstimRecipe;
companionRecipeLabel = overstimLabel;
} else if (shakeRecipe && shakeLabel) {
companionRecipe = shakeRecipe;
companionRecipeLabel = shakeLabel;
} else {
companionRecipe = statusRecipe;
companionRecipeLabel = statusRecipeLabel;
@@ -305,14 +351,15 @@ export function BlobbiCompanionLayer() {
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={startDrag}
onStartDrag={handleStartDrag}
onUpdateDrag={updateDrag}
onEndDrag={endDrag}
onEndDrag={handleEndDrag}
onClick={handleCompanionClick}
isClickBlocked={isOverstimBlocked}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
onDragSample={handleDragSample}
debugMode={DEBUG_GROUND_CONTACT}
/>
</div>
+222
View File
@@ -0,0 +1,222 @@
/**
* Shake Detection — Pure utility for detecting vigorous shaking during drag.
*
* Records pointer position samples in a sliding time window and computes a
* "shake intensity" score based on:
* 1. Average speed of pointer movement (px/s)
* 2. Number of direction reversals (oscillation count)
*
* This is a reusable "motion stress" signal — future systems can consume
* the same samples to detect different physical interactions (e.g. spinning,
* slamming, sustained vibration).
*
* The module is framework-agnostic (no React). All state lives in a plain
* object that the caller creates and passes to each function.
*
* Usage:
* const tracker = createShakeTracker();
* // On each pointer move during drag:
* recordSample(tracker, { x, y });
* // On drag end:
* const result = computeShakeResult(tracker);
* resetTracker(tracker);
*/
import type { Position } from '../types/companion.types';
// ─── Configuration ────────────────────────────────────────────────────────────
/** How long samples are kept (ms). Older samples are pruned. */
const SAMPLE_WINDOW_MS = 2000;
/** Minimum time between recorded samples (ms). Prevents over-sampling at
* high pointer event rates (120 Hz+ on modern devices). */
const MIN_SAMPLE_INTERVAL_MS = 16; // ~60 samples/s max
/** Minimum speed (px/s) for movement to count as "vigorous".
* Normal gentle dragging stays well below this. */
const SPEED_THRESHOLD = 400;
/** Minimum direction reversals in the window for it to count as "shaking"
* rather than just fast linear dragging. */
const REVERSAL_THRESHOLD = 3;
/**
* Minimum cumulative shake energy (speed * reversals * time) before a
* shake is considered "meaningful". Prevents micro-shakes from triggering.
* Tuned so ~1s of moderate shaking crosses this.
*/
const MIN_SHAKE_ENERGY = 800;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MotionSample {
x: number;
y: number;
t: number; // performance.now() timestamp
}
/**
* Mutable state object for the shake tracker.
* Caller creates this once and passes it to each function.
*/
export interface ShakeTracker {
samples: MotionSample[];
/** Running sum of per-segment speed values in the current window. */
speedAccumulator: number;
/** Number of direction reversals detected in the current window. */
reversalCount: number;
/** Last recorded movement direction (for reversal detection). */
lastDx: number;
lastDy: number;
/** Whether we have a valid "last direction" yet. */
hasDirection: boolean;
/** Accumulated shake energy across the drag session. Energy is the
* integral of (instantaneous speed * reversal density) over time.
* It only grows while the user is actively shaking. */
energy: number;
}
/**
* Result of shake analysis after drag ends.
*/
export interface ShakeResult {
/** Whether the shake was meaningful enough to trigger a reaction. */
triggered: boolean;
/** Normalized shake intensity (01). 0 = no shake, 1 = maximum shake. */
intensity: number;
/** Accumulated energy value for duration scaling. */
energy: number;
/** Duration of the active shaking portion (ms). */
shakeDurationMs: number;
}
// ─── API ──────────────────────────────────────────────────────────────────────
/** Create a fresh shake tracker. */
export function createShakeTracker(): ShakeTracker {
return {
samples: [],
speedAccumulator: 0,
reversalCount: 0,
lastDx: 0,
lastDy: 0,
hasDirection: false,
energy: 0,
};
}
/** Reset a tracker for reuse (avoids allocation). */
export function resetTracker(tracker: ShakeTracker): void {
tracker.samples.length = 0;
tracker.speedAccumulator = 0;
tracker.reversalCount = 0;
tracker.lastDx = 0;
tracker.lastDy = 0;
tracker.hasDirection = false;
tracker.energy = 0;
}
/**
* Record a pointer position sample.
*
* Call this on every pointer move event during drag. The function
* handles its own rate-limiting and pruning.
*/
export function recordSample(tracker: ShakeTracker, position: Position): void {
const now = performance.now();
const { samples } = tracker;
// Rate-limit: skip if too close to the last sample
if (samples.length > 0) {
const last = samples[samples.length - 1]!;
if (now - last.t < MIN_SAMPLE_INTERVAL_MS) return;
}
// Push new sample
samples.push({ x: position.x, y: position.y, t: now });
// Compute instantaneous velocity from the last two samples
if (samples.length >= 2) {
const prev = samples[samples.length - 2]!;
const curr = samples[samples.length - 1]!;
const dt = (curr.t - prev.t) / 1000; // seconds
if (dt > 0) {
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / dt;
tracker.speedAccumulator += speed;
// Direction reversal detection (dot product of consecutive deltas)
if (tracker.hasDirection) {
const dot = dx * tracker.lastDx + dy * tracker.lastDy;
if (dot < 0) {
// Direction reversed
tracker.reversalCount++;
}
}
// Update last direction (only if movement was non-trivial)
if (dist > 2) {
tracker.lastDx = dx;
tracker.lastDy = dy;
tracker.hasDirection = true;
}
// Accumulate energy when movement is vigorous
if (speed > SPEED_THRESHOLD && tracker.reversalCount >= 1) {
tracker.energy += speed * dt;
}
}
}
// Prune old samples outside the window
const cutoff = now - SAMPLE_WINDOW_MS;
while (samples.length > 0 && samples[0]!.t < cutoff) {
samples.shift();
}
}
/**
* Compute the shake result from the current tracker state.
*
* Call this when the drag ends to determine if shaking occurred
* and how intense it was.
*/
export function computeShakeResult(tracker: ShakeTracker): ShakeResult {
const { samples, speedAccumulator, reversalCount, energy } = tracker;
const sampleCount = samples.length;
// Not enough data
if (sampleCount < 4) {
return { triggered: false, intensity: 0, energy: 0, shakeDurationMs: 0 };
}
const avgSpeed = speedAccumulator / (sampleCount - 1);
const isVigorous = avgSpeed > SPEED_THRESHOLD;
const isOscillating = reversalCount >= REVERSAL_THRESHOLD;
const hasSufficientEnergy = energy >= MIN_SHAKE_ENERGY;
const triggered = isVigorous && isOscillating && hasSufficientEnergy;
if (!triggered) {
return { triggered: false, intensity: 0, energy: 0, shakeDurationMs: 0 };
}
// Compute shake duration from first to last sample
const shakeDurationMs = samples[sampleCount - 1]!.t - samples[0]!.t;
// Normalize intensity (01) based on energy
// Tuning: ~800 = minimum, ~5000 = maximum (about 4s of vigorous shaking)
const normalizedEnergy = Math.min(1, (energy - MIN_SHAKE_ENERGY) / 4200);
// Also factor in reversal density (more reversals = more shaky)
const reversalDensity = Math.min(1, reversalCount / 20);
// Weighted combination: energy dominates, reversals add bonus
const intensity = Math.min(1, normalizedEnergy * 0.7 + reversalDensity * 0.3);
return { triggered, intensity, energy, shakeDurationMs };
}
@@ -225,6 +225,9 @@ export function useOverstimulationReaction({
levelRef.current = 0;
phaseRef.current = 'idle';
toastShownRef.current = false;
// Clear the sliding window so stale timestamps from this cycle
// don't interfere with the next activation attempt.
clicksRef.current.length = 0;
clearRaf();
return;
}
@@ -0,0 +1,423 @@
/**
* useShakeReaction — Blobbi reacts to being shaken during drag.
*
* Produces a live visual reaction while the user is actively shaking,
* and sustains the dizzy state after release for a duration proportional
* to the total shake intensity.
*
* 1. **Shaking phase** (during drag): When shake energy crosses the
* trigger threshold, Blobbi immediately looks dizzy. If nausea is
* eligible, the green body fill rises in real time as the user
* continues shaking.
*
* 2. **Dizzy phase** (after release): The dizzy expression and any
* accumulated nausea fill are held for a duration that scales with
* the final shake intensity (~38 s).
*
* 3. **Recovering phase**: Nausea fill drains gradually via rAF.
* Once fully drained, transitions to idle.
*
* Architecture notes:
* - Follows the same phase/level/profile pattern as useOverstimulationReaction
* - The **ShakeReactionProfile** interface enables future personality
* variants (e.g. a hardy Blobbi that resists nausea, or one that
* gets scared instead of dizzy)
* - The nausea level caps at 1.0, leaving room for future escalation
* phases (e.g. additional outcomes at max nausea).
* - The dizzy recipe reuses the existing EMOTION_RECIPES.dizzy preset
*
* Phases:
* - idle: No shake reaction active
* - shaking: User is actively shaking (dizzy face + live nausea fill)
* - dizzy: Post-release hold (spiral eyes, sustained nausea level)
* - recovering: Nausea draining (rAF loop)
*
* Performance: Same ref+rAF pattern as overstimulation. Visible level
* state updates are throttled to ~610 fps.
*/
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { toast } from '@/hooks/useToast';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import { resolveVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { ShakeResult } from '../core/shakeDetection';
// ─── Profile System ───────────────────────────────────────────────────────────
/**
* Maps shake reaction states to visual recipes.
*
* Future personalities can supply different profiles to produce different
* reactions (e.g. scared instead of dizzy, or resistant to nausea).
*/
export interface ShakeReactionProfile {
/** Recipe for the dizzy state (face only, no body fill). */
dizzy: {
recipe: BlobbiVisualRecipe;
label: string;
};
/** Recipe for the dizzy+nausea state (face; body fill is added dynamically). */
nauseated: {
recipe: BlobbiVisualRecipe;
label: string;
};
/** Color used for the nausea body fill effect. */
nauseaFillColor: string;
/** Opacity at the bottom of the nausea fill (01). Default: 0.78. */
nauseaBottomOpacity?: number;
/** Opacity at the feathered top edge of the nausea fill (01). Default: 0.65. */
nauseaEdgeOpacity?: number;
}
/** Dizzy-only recipe: reuse the existing emotion preset. */
const DIZZY_RECIPE = resolveVisualRecipe('dizzy');
/**
* Nauseated recipe: same dizzy eyes but with a queasy mouth twist.
* The green body fill is added dynamically based on nausea level.
*/
const NAUSEATED_RECIPE: BlobbiVisualRecipe = {
...DIZZY_RECIPE,
// Slightly different mouth — wider, more distressed
mouth: { roundMouth: { rx: 5, ry: 6, filled: true } },
eyebrows: {
config: { angle: -15, offsetY: -12, strokeWidth: 1.5, color: '#6b7280', curve: 0.15 },
},
};
/** Default profile: dizzy + dark cartoon-sickness green nausea. */
export const DIZZY_NAUSEA_PROFILE: ShakeReactionProfile = {
dizzy: { recipe: DIZZY_RECIPE, label: 'dizzy' },
nauseated: { recipe: NAUSEATED_RECIPE, label: 'nauseated' },
// Dark cartoon-sickness green — stylized, not neon, not realistic
nauseaFillColor: '#4a7a3d',
// Strong presence so Blobbi visibly turns green/sick
nauseaBottomOpacity: 0.78,
nauseaEdgeOpacity: 0.65,
};
// ─── Thresholds & Timing ──────────────────────────────────────────────────────
/**
* Hunger stat at or above which shaking triggers nausea (very full).
*
* TEMPORARY DEBUG: The threshold check is currently bypassed so nausea
* triggers on every shake regardless of hunger. See the `isNauseated`
* assignment inside `onDragUpdate` and `onDragEnd`. Restore the real
* threshold by removing the `true ||` override.
*/
const _NAUSEA_HUNGER_THRESHOLD = 90;
/** Minimum dizzy duration (seconds) for a barely-qualifying shake. */
const MIN_DIZZY_DURATION_S = 3;
/** Maximum dizzy duration (seconds) for the most intense shake. */
const MAX_DIZZY_DURATION_S = 8;
/** Rate at which nausea level drains during recovery (units/s).
* Full nausea (1.0) takes ~4 s to drain. */
const NAUSEA_DRAIN_RATE = 0.25;
/** Minimum delta before pushing a visible nausea level update. */
const VISIBLE_LEVEL_THRESHOLD = 0.02;
// ─── Types ────────────────────────────────────────────────────────────────────
export type ShakeReactionPhase = 'idle' | 'shaking' | 'dizzy' | 'recovering';
export interface UseShakeReactionOptions {
/** Whether the hook is active. */
isActive: boolean;
/** Current hunger stat value (1100). Used to determine nausea eligibility. */
hunger: number;
/** Visual profile. Defaults to DIZZY_NAUSEA_PROFILE. */
profile?: ShakeReactionProfile;
}
export interface UseShakeReactionResult {
/** Current phase. */
phase: ShakeReactionPhase;
/** Current nausea level (01), throttled for rendering. 0 when no nausea. */
nauseaLevel: number;
/** Visual recipe override, or null when idle. */
recipe: BlobbiVisualRecipe | null;
/** Human-readable label for the recipe. */
recipeLabel: string | null;
/** Call this on each drag sample with the live ShakeResult. */
onDragUpdate: (result: ShakeResult) => void;
/** Call this when drag ends with the final ShakeResult. */
onDragEnd: (result: ShakeResult) => void;
/** Call this when drag starts (resets any active reaction). */
onDragStart: () => void;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useShakeReaction({
isActive,
hunger,
profile = DIZZY_NAUSEA_PROFILE,
}: UseShakeReactionOptions): UseShakeReactionResult {
// ── Visible state (throttled) ──
const [visibleNauseaLevel, setVisibleNauseaLevel] = useState(0);
const [phase, setPhase] = useState<ShakeReactionPhase>('idle');
// ── Refs for high-frequency data ──
const nauseaLevelRef = useRef(0);
const phaseRef = useRef<ShakeReactionPhase>('idle');
const lastVisibleRef = useRef(0);
const rafRef = useRef<number | null>(null);
const dizzyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevTimeRef = useRef(0);
const hungerRef = useRef(hunger);
hungerRef.current = hunger;
const toastShownRef = useRef(false);
const profileRef = useRef(profile);
profileRef.current = profile;
// ── Helpers ──
const clearRaf = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}, []);
const clearDizzyTimer = useCallback(() => {
if (dizzyTimerRef.current !== null) {
clearTimeout(dizzyTimerRef.current);
dizzyTimerRef.current = null;
}
}, []);
const pushVisible = useCallback((level: number, p: ShakeReactionPhase) => {
const delta = Math.abs(level - lastVisibleRef.current);
const phaseChanged = p !== phaseRef.current;
if (phaseChanged || delta >= VISIBLE_LEVEL_THRESHOLD || (level === 0 && lastVisibleRef.current !== 0)) {
lastVisibleRef.current = level;
setVisibleNauseaLevel(level);
}
if (phaseChanged) {
phaseRef.current = p;
setPhase(p);
}
}, []);
// ── rAF nausea drain loop ──
// Runs during BOTH 'dizzy' and 'recovering' phases so the green fill
// begins descending the moment shaking stops, not only after the dizzy
// hold ends. The drain rate is the same in both phases for a smooth,
// continuous descent.
const rafTick = useCallback((now: number) => {
const dt = prevTimeRef.current > 0 ? (now - prevTimeRef.current) / 1000 : 0;
prevTimeRef.current = now;
const currentPhase = phaseRef.current;
if (currentPhase === 'dizzy' || currentPhase === 'recovering') {
if (nauseaLevelRef.current <= 0) {
nauseaLevelRef.current = 0;
// During dizzy hold, keep the phase — the timer will handle the
// dizzy→idle transition. During recovering, go straight to idle.
if (currentPhase === 'recovering') {
pushVisible(0, 'idle');
// Clean up cycle-scoped refs so next shake starts fresh.
toastShownRef.current = false;
} else {
pushVisible(0, 'dizzy');
}
clearRaf();
return;
}
const newLevel = Math.max(0, nauseaLevelRef.current - NAUSEA_DRAIN_RATE * dt);
nauseaLevelRef.current = newLevel;
if (newLevel <= 0) {
if (currentPhase === 'recovering') {
pushVisible(0, 'idle');
toastShownRef.current = false;
} else {
pushVisible(0, 'dizzy');
}
clearRaf();
return;
}
// Stay in the current phase — level changed but phase didn't
pushVisible(newLevel, currentPhase);
} else {
clearRaf();
return;
}
rafRef.current = requestAnimationFrame(rafTick);
}, [pushVisible, clearRaf]);
const startRafLoop = useCallback(() => {
if (rafRef.current !== null) return;
prevTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(rafTick);
}, [rafTick]);
const startRafLoopRef = useRef(startRafLoop);
startRafLoopRef.current = startRafLoop;
// ── Full reset ──
const resetAll = useCallback(() => {
clearRaf();
clearDizzyTimer();
nauseaLevelRef.current = 0;
phaseRef.current = 'idle';
lastVisibleRef.current = 0;
toastShownRef.current = false;
setVisibleNauseaLevel(0);
setPhase('idle');
}, [clearRaf, clearDizzyTimer]);
// ── Drag start: cancel any active reaction ──
const onDragStart = useCallback(() => {
if (phaseRef.current !== 'idle') {
resetAll();
}
// Fresh drag — reset toast guard for this cycle
toastShownRef.current = false;
}, [resetAll]);
// ── Live drag update: transition to shaking when threshold crossed ──
const onDragUpdate = useCallback((result: ShakeResult) => {
if (!result.triggered) return;
// TEMPORARY DEBUG: bypass hunger threshold so nausea triggers on every
// shake for easier testing. The real check is:
// const isNauseated = hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD;
// TODO: restore the real threshold when debug testing is complete.
// eslint-disable-next-line no-constant-binary-expression
const isNauseated = true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD;
const nauseaLevel = isNauseated ? Math.min(1, result.intensity) : 0;
// Update nausea level live
nauseaLevelRef.current = nauseaLevel;
// Transition idle → shaking on first trigger
if (phaseRef.current === 'idle' || phaseRef.current === 'shaking') {
pushVisible(nauseaLevel, 'shaking');
// Show nausea warning toast once per shake cycle
if (isNauseated && !toastShownRef.current) {
toastShownRef.current = true;
toast({
title: 'Careful\u2026',
description: 'Blobbi is feeling sick!',
});
}
}
}, [pushVisible]);
// ── Drag end: finalize and hold dizzy ──
const onDragEnd = useCallback((result: ShakeResult) => {
// If we were in shaking phase, always finalize (even if the
// final result is below threshold — the user already saw the reaction)
const wasShaking = phaseRef.current === 'shaking';
if (!result.triggered && !wasShaking) return;
// Calculate dizzy duration from final intensity
const intensity = result.triggered ? result.intensity : 0;
const dizzyDurationS = MIN_DIZZY_DURATION_S +
intensity * (MAX_DIZZY_DURATION_S - MIN_DIZZY_DURATION_S);
// TEMPORARY DEBUG: bypass hunger threshold (same as onDragUpdate).
// TODO: restore the real threshold when debug testing is complete.
// eslint-disable-next-line no-constant-binary-expression
const isNauseated = true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD;
const nauseaLevel = isNauseated ? Math.min(1, intensity) : 0;
// Lock in the final nausea level and start draining immediately
nauseaLevelRef.current = nauseaLevel;
pushVisible(nauseaLevel, 'dizzy');
// Start the rAF drain loop right away so the green fill begins
// descending during the dizzy hold, not only after it ends.
if (nauseaLevel > 0) {
startRafLoopRef.current();
}
// Schedule end of dizzy hold
clearDizzyTimer();
dizzyTimerRef.current = setTimeout(() => {
dizzyTimerRef.current = null;
if (nauseaLevelRef.current > 0) {
// Nausea still draining — transition to recovering (loop is
// already running, it will pick up the new phase automatically)
pushVisible(nauseaLevelRef.current, 'recovering');
} else {
pushVisible(0, 'idle');
toastShownRef.current = false;
}
}, dizzyDurationS * 1000);
}, [pushVisible, clearDizzyTimer]);
// ── Reset on deactivation ──
useEffect(() => {
if (!isActive) {
resetAll();
}
}, [isActive, resetAll]);
// ── Cleanup on unmount ──
useEffect(() => {
return () => {
clearRaf();
clearDizzyTimer();
};
}, [clearRaf, clearDizzyTimer]);
// ── Resolve phase + nausea level → recipe ──
const result = useMemo((): UseShakeReactionResult => {
const base = { onDragUpdate, onDragEnd, onDragStart };
if (phase === 'idle') {
return { ...base, phase: 'idle', nauseaLevel: 0, recipe: null, recipeLabel: null };
}
const p = profileRef.current;
// ── Nauseated: dizzy face + green body fill ──
if (visibleNauseaLevel > 0) {
const recipe: BlobbiVisualRecipe = {
...p.nauseated.recipe,
bodyEffects: {
...p.nauseated.recipe.bodyEffects,
angerRise: {
color: p.nauseaFillColor,
duration: 0,
level: visibleNauseaLevel,
bottomOpacity: p.nauseaBottomOpacity,
edgeOpacity: p.nauseaEdgeOpacity,
},
},
};
return { ...base, phase, nauseaLevel: visibleNauseaLevel, recipe, recipeLabel: p.nauseated.label };
}
// ── Dizzy only (no nausea fill) ──
return { ...base, phase, nauseaLevel: 0, recipe: p.dizzy.recipe, recipeLabel: p.dizzy.label };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, visibleNauseaLevel, profile, onDragUpdate, onDragEnd, onDragStart]);
return result;
}
+70 -2
View File
@@ -21,7 +21,7 @@
* inside the SVG continue running across parent rerenders.
*/
import { useMemo } from 'react';
import { useMemo, useRef, useEffect } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
@@ -70,6 +70,30 @@ export function BlobbiAdultSvgRenderer({
bodyEffects,
className,
}: BlobbiAdultSvgRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
// ── Structural recipe fingerprint ──────────────────────────────────────────
// Shallow-clones the recipe and strips only `angerRise.level` — the one
// field that changes at ~12 Hz during nausea/anger drain. Everything else
// (face parts, fill color, opacity, body effects) is preserved in the
// fingerprint so structural changes still trigger a full SVG rebuild.
//
// Because useMemo compares the resulting string by value (not reference),
// level-only changes produce the same fingerprint → `customizedSvg`
// memo stays stable → SVG DOM is preserved → SMIL animations survive.
const recipeFingerprint = useMemo(() => {
if (!recipeProp) return '';
const { bodyEffects, ...rest } = recipeProp;
if (!bodyEffects) return JSON.stringify(rest);
const { angerRise, ...otherEffects } = bodyEffects;
if (!angerRise) return JSON.stringify({ ...rest, bodyEffects: otherEffects });
const { level: _level, ...stableAngerRise } = angerRise;
return JSON.stringify({
...rest,
bodyEffects: { ...otherEffects, angerRise: stableAngerRise },
});
}, [recipeProp]);
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild');
@@ -91,12 +115,56 @@ export function BlobbiAdultSvgRenderer({
}
return animatedSvg;
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
// recipeFingerprint replaces recipeProp in the dep list so that
// level-only changes do NOT trigger a full SVG rebuild. The closure
// captures the current recipeProp for the rare structural rebuilds.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blobbi, recipeFingerprint, recipeLabel, emotion, bodyEffects]);
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
// ── Imperative fill level update ──────────────────────────────────────────
// When only the angerRise level changes (~12× /sec during nausea drain),
// skip the full SVG rebuild and update the gradient stops directly on
// the existing DOM. This preserves SMIL animations (dizzy spirals,
// sleepy blink, etc.) that would be killed by dangerouslySetInnerHTML.
//
// Gradient ID contract:
// applyVisualRecipe() passes blobbi.id as instanceId
// → recipe.ts sets bodySpec.idPrefix = instanceId
// → apply.ts uses idSuffix = spec.idPrefix
// → generators.ts creates gradientId = `blobbi-anger-gradient-${idSuffix}`
//
// So the gradient ID in the SVG DOM is deterministically
// `blobbi-anger-gradient-${blobbi.id}`, which we look up below.
// The 3 stops in the static-level gradient are (bottom, edge, transparent),
// matching the order in generateAngerRiseEffect() (generators.ts).
const fillLevel = recipeProp?.bodyEffects?.angerRise?.level;
useEffect(() => {
const container = containerRef.current;
if (!container || fillLevel === undefined) return;
const gradientId = `blobbi-anger-gradient-${blobbi.id}`;
const gradient = container.querySelector(`#${CSS.escape(gradientId)}`);
if (!gradient) return;
const stops = gradient.querySelectorAll('stop');
if (stops.length < 3) return;
// Matches the feather constant in generateAngerRiseEffect().
const feather = 0.10;
const edgeOffset = Math.max(0, fillLevel - feather);
// stops[0] = bottom (unchanged — its offset is always 0%)
// stops[1] = feathered edge — moves with fill level
// stops[2] = transparent top — moves with fill level
stops[1]?.setAttribute('offset', String(edgeOffset));
stops[2]?.setAttribute('offset', String(fillLevel));
}, [fillLevel, blobbi.id]);
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
+42 -2
View File
@@ -17,7 +17,7 @@
* - Companion runtime (drag, float, position)
*/
import { useMemo } from 'react';
import { useMemo, useRef, useEffect } from 'react';
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
@@ -66,6 +66,24 @@ export function BlobbiBabySvgRenderer({
bodyEffects,
className,
}: BlobbiBabySvgRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
// ── Structural recipe fingerprint (see adult renderer for full explanation) ──
// Clones the recipe and strips only bodyEffects.angerRise.level so that
// level-only changes do not trigger a full SVG rebuild.
const recipeFingerprint = useMemo(() => {
if (!recipeProp) return '';
const { bodyEffects, ...rest } = recipeProp;
if (!bodyEffects) return JSON.stringify(rest);
const { angerRise, ...otherEffects } = bodyEffects;
if (!angerRise) return JSON.stringify({ ...rest, bodyEffects: otherEffects });
const { level: _level, ...stableAngerRise } = angerRise;
return JSON.stringify({
...rest,
bodyEffects: { ...otherEffects, angerRise: stableAngerRise },
});
}, [recipeProp]);
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild');
@@ -87,12 +105,34 @@ export function BlobbiBabySvgRenderer({
}
return animatedSvg;
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blobbi, recipeFingerprint, recipeLabel, emotion, bodyEffects]);
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
// ── Imperative fill level update (see adult renderer for full explanation) ──
const fillLevel = recipeProp?.bodyEffects?.angerRise?.level;
useEffect(() => {
const container = containerRef.current;
if (!container || fillLevel === undefined) return;
const gradientId = `blobbi-anger-gradient-${blobbi.id}`;
const gradient = container.querySelector(`#${CSS.escape(gradientId)}`);
if (!gradient) return;
const stops = gradient.querySelectorAll('stop');
if (stops.length < 3) return;
const feather = 0.10;
const edgeOffset = Math.max(0, fillLevel - feather);
stops[1]?.setAttribute('offset', String(edgeOffset));
stops[2]?.setAttribute('offset', String(fillLevel));
}, [fillLevel, blobbi.id]);
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
+2
View File
@@ -77,6 +77,8 @@ export function applyBodyEffects(svgText: string, spec: BodyEffectsSpec): string
color: spec.angerRise.color,
duration: spec.angerRise.duration,
level: spec.angerRise.level,
bottomOpacity: spec.angerRise.bottomOpacity,
edgeOpacity: spec.angerRise.edgeOpacity,
},
idSuffix,
);
+27 -8
View File
@@ -625,8 +625,9 @@ export function generateAngerRiseEffect(
config: BodyEffectConfig,
idSuffix?: string,
): { defs: string; overlay: string } {
const { pathD, minY, maxY } = bodyPath;
const { pathD, minX, maxX, minY, maxY } = bodyPath;
const bodyHeight = maxY - minY;
const bodyWidth = maxX - minX;
const suffix = idSuffix ?? Math.random().toString(36).slice(2, 8);
const clipId = `blobbi-anger-clip-${suffix}`;
@@ -634,12 +635,23 @@ export function generateAngerRiseEffect(
// When `level` is provided, render a static gradient at that offset (01)
// instead of using the SMIL rise animation. This lets external systems
// (e.g. overstimulation reaction) control exact fill height each frame.
// (e.g. overstimulation reaction, nausea) control exact fill height each frame.
//
// `level` controls only how HIGH the fill reaches. Opacity is controlled
// separately via bottomOpacity/edgeOpacity so different effects (anger vs
// nausea) can have different visual intensities through the same generator.
const useStaticLevel = config.level !== undefined && config.level !== null;
const lvl = useStaticLevel ? Math.max(0, Math.min(1, config.level!)) : 0;
// Feather zone: percentage of the gradient used for the soft top edge
const feather = 0.08;
// Caller-controlled opacity with moderate defaults.
// Nausea uses stronger values (~0.78/0.65); anger uses these defaults.
const bottomOpacity = config.bottomOpacity ?? 0.55;
const edgeOpacity = config.edgeOpacity ?? 0.45;
// Feather zone: fraction of the gradient used for the soft top edge.
// Slightly larger than the animated path to keep the edge soft when the
// fill height is re-rendered every frame.
const feather = 0.10;
const defs = useStaticLevel
? `
@@ -647,8 +659,8 @@ export function generateAngerRiseEffect(
<path d="${pathD}" />
</clipPath>
<linearGradient id="${gradientId}" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="${config.color}" stop-opacity="${0.5 * lvl}" />
<stop offset="${Math.max(0, lvl - feather)}" stop-color="${config.color}" stop-opacity="${0.4 * lvl}" />
<stop offset="0%" stop-color="${config.color}" stop-opacity="${bottomOpacity}" />
<stop offset="${Math.max(0, lvl - feather)}" stop-color="${config.color}" stop-opacity="${edgeOpacity}" />
<stop offset="${lvl}" stop-color="${config.color}" stop-opacity="0" />
</linearGradient>`
: `
@@ -690,11 +702,18 @@ export function generateAngerRiseEffect(
</stop>
</linearGradient>`;
// Use detected body bounds with a small pad to ensure the rect fully
// covers the body silhouette for both baby (100x100) and adult (200x200)
// viewBoxes. The clip-path masks any overshoot.
const pad = 2;
const rectX = minX - pad;
const rectW = bodyWidth + pad * 2;
const overlay = `
<rect
class="blobbi-anger-rise"
x="0" y="${minY}"
width="100" height="${bodyHeight}"
x="${rectX}" y="${minY}"
width="${rectW}" height="${bodyHeight}"
fill="url(#${gradientId})"
clip-path="url(#${clipId})"
/>`;
+13 -1
View File
@@ -78,6 +78,18 @@ export interface BodyEffectConfig {
* how far up the body the color fill reaches.
*/
level?: number;
/**
* Opacity at the bottom of the fill (01). Controls how strongly the
* color reads at the base. Higher = more present.
* Default: 0.55 (moderate — clearly visible but not overwhelming).
*/
bottomOpacity?: number;
/**
* Opacity at the feathered top edge of the fill (01). Controls the
* intensity just before the fill fades to transparent.
* Default: 0.45 (slightly softer than bottom for a natural gradient).
*/
edgeOpacity?: number;
}
/**
@@ -90,7 +102,7 @@ export interface BodyEffectsSpec {
/** Stink/odor visuals around the body */
stinkClouds?: StinkCloudsConfig;
/** Anger-rise color overlay inside body */
angerRise?: { color: string; duration: number; level?: number };
angerRise?: { color: string; duration: number; level?: number; bottomOpacity?: number; edgeOpacity?: number };
/**
* Unique ID prefix for SVG defs (clip paths, gradients).
* Required when multiple Blobbis render on the same page to avoid ID collisions.
+2 -6
View File
@@ -144,7 +144,7 @@ export interface BodyEffectsRecipe {
/** Stink cloud puffs */
stinkClouds?: StinkCloudsConfig;
/** Anger-rise color overlay */
angerRise?: { color: string; duration: number; level?: number };
angerRise?: { color: string; duration: number; level?: number; bottomOpacity?: number; edgeOpacity?: number };
}
/**
@@ -953,11 +953,7 @@ export function applyVisualRecipe(
bodySpec.stinkClouds = recipe.bodyEffects.stinkClouds;
}
if (recipe.bodyEffects.angerRise) {
bodySpec.angerRise = {
color: recipe.bodyEffects.angerRise.color,
duration: recipe.bodyEffects.angerRise.duration,
level: recipe.bodyEffects.angerRise.level,
};
bodySpec.angerRise = recipe.bodyEffects.angerRise;
}
if (instanceId) {
bodySpec.idPrefix = instanceId;