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:
Generated
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 (0–1). 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 (0–1) 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 (~3–8 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 ~6–10 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 (0–1). Default: 0.78. */
|
||||
nauseaBottomOpacity?: number;
|
||||
/** Opacity at the feathered top edge of the nausea fill (0–1). 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 (1–100). 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 (0–1), 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;
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 (0–1)
|
||||
// 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})"
|
||||
/>`;
|
||||
|
||||
@@ -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 (0–1). 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 (0–1). 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user