Merge branch 'feat/blobbi-click-overstimulation-reaction' into 'main'

Add Blobbi overstimulation reaction for repeated clicks

Closes #236

See merge request soapbox-pub/ditto!190
This commit is contained in:
Chad Curtis
2026-04-24 03:47:04 +00:00
15 changed files with 933 additions and 89 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.10.3",
"version": "2.10.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.10.3",
"version": "2.10.4",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -62,6 +62,8 @@ interface BlobbiCompanionProps {
onEndDrag: () => void;
/** Click callback (when interaction is a click, not a drag) */
onClick?: () => void;
/** When true, Blobbi ignores click interactions (overstimulation block). */
isClickBlocked?: boolean;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (CSS class names). */
@@ -77,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({
@@ -94,20 +102,24 @@ export function BlobbiCompanion({
onUpdateDrag,
onEndDrag,
onClick,
isClickBlocked = false,
recipe,
recipeLabel,
emotion,
bodyEffects,
onPositionUpdate,
debugMode = false,
onDragSample,
}: BlobbiCompanionProps) {
const config = DEFAULT_COMPANION_CONFIG;
const containerRef = useRef<HTMLDivElement>(null);
const [animationTime, setAnimationTime] = useState(0);
// Click detection - distinguishes click from drag
// Click detection - distinguishes click from drag.
// When overstimulation blocks clicks, suppress the onClick callback.
const effectiveOnClick = isClickBlocked ? undefined : onClick;
const clickDetection = useClickDetection({
onClick,
onClick: effectiveOnClick,
onDragStart: onStartDrag,
});
@@ -254,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) {
@@ -274,6 +289,7 @@ export function BlobbiCompanion({
return (
<div
ref={containerRef}
data-blobbi-companion
className="select-none touch-none"
style={{
position: 'fixed',
@@ -14,12 +14,16 @@
* 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 { OverstimulationBlockOverlay } from './OverstimulationBlockOverlay';
import { DebugGroundOverlay } from './DebugGroundOverlay';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import { calculateGroundY } from '../utils/movement';
@@ -136,6 +140,55 @@ export function BlobbiCompanionLayer() {
const { actionOverride, triggerOverride } = useActionEmotionOverride();
// ── Overstimulation reaction ───────────────────────────────────────────────
const {
recipe: overstimRecipe,
recipeLabel: overstimLabel,
isBlocked: isOverstimBlocked,
} = useOverstimulationReaction({
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];
@@ -237,13 +290,28 @@ export function BlobbiCompanionLayer() {
actionOverride: isSleeping ? null : actionOverride,
});
// When sleeping, overlay the sleeping face on top of the status recipe.
// This keeps body effects (dirty, stink) and food icon while overriding
// eyes, mouth, and eyebrows with sleeping visuals.
const companionRecipe = isSleeping
? buildSleepingRecipe(statusRecipe)
: statusRecipe;
const companionRecipeLabel = isSleeping ? 'sleeping' : statusRecipeLabel;
// Recipe priority chain (highest → lowest):
// 1. Sleeping (always wins when companion is asleep)
// 2. Overstimulation reaction (user spam-clicking)
// 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;
if (isSleeping) {
companionRecipe = buildSleepingRecipe(statusRecipe);
companionRecipeLabel = 'sleeping';
} else if (overstimRecipe && overstimLabel) {
companionRecipe = overstimRecipe;
companionRecipeLabel = overstimLabel;
} else if (shakeRecipe && shakeLabel) {
companionRecipe = shakeRecipe;
companionRecipeLabel = shakeLabel;
} else {
companionRecipe = statusRecipe;
companionRecipeLabel = statusRecipeLabel;
}
// ── Early return ───────────────────────────────────────────────────────────
@@ -256,69 +324,78 @@ export function BlobbiCompanionLayer() {
const debugGroundY = calculateGroundY(viewport.height, config.size, config);
return (
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 9999 }}
aria-hidden="true"
>
{DEBUG_GROUND_CONTACT && (
<DebugGroundOverlay
groundY={debugGroundY}
size={config.size}
viewportHeight={viewport.height}
paddingBottom={config.padding.bottom}
isEntering={isEntering}
entryState={entryState}
/>
)}
<>
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 9999 }}
aria-hidden="true"
>
{DEBUG_GROUND_CONTACT && (
<DebugGroundOverlay
groundY={debugGroundY}
size={config.size}
viewportHeight={viewport.height}
paddingBottom={config.padding.bottom}
isEntering={isEntering}
entryState={entryState}
/>
)}
<div className="pointer-events-auto">
<BlobbiCompanion
companion={companion}
state={state}
motion={motion}
eyeOffsetRef={eyeOffsetRef}
isEntering={isEntering}
entryProgress={entryProgress}
entryState={entryState}
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={startDrag}
onUpdateDrag={updateDrag}
onEndDrag={endDrag}
onClick={handleCompanionClick}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
debugMode={DEBUG_GROUND_CONTACT}
<div className="pointer-events-auto">
<BlobbiCompanion
companion={companion}
state={state}
motion={motion}
eyeOffsetRef={eyeOffsetRef}
isEntering={isEntering}
entryProgress={entryProgress}
entryState={entryState}
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={handleStartDrag}
onUpdateDrag={updateDrag}
onEndDrag={handleEndDrag}
onClick={handleCompanionClick}
isClickBlocked={isOverstimBlocked}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
onDragSample={handleDragSample}
debugMode={DEBUG_GROUND_CONTACT}
/>
</div>
<CompanionActionMenu
isOpen={menuState.isOpen}
companionPosition={renderedPosition}
companionSize={config.size}
actions={availableActions}
selectedAction={menuState.selectedAction}
onActionClick={handleActionClick}
onClickOutside={handleClickOutside}
isSleeping={isSleeping}
/>
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
items={menuState.items}
viewportHeight={viewport.height}
groundOffset={config.padding.bottom}
companionPosition={renderedPosition}
companionSize={config.size}
onItemRelease={handleItemClick}
onItemLanded={handleItemLanded}
onItemUse={handleItemUse}
isItemOnCooldown={isItemOnCooldown}
/>
</div>
<CompanionActionMenu
isOpen={menuState.isOpen}
companionPosition={renderedPosition}
companionSize={config.size}
actions={availableActions}
selectedAction={menuState.selectedAction}
onActionClick={handleActionClick}
onClickOutside={handleClickOutside}
isSleeping={isSleeping}
{/* Overlay sits outside the zoom container so it stays at viewport scale */}
<OverstimulationBlockOverlay
isBlocked={isOverstimBlocked}
/>
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
items={menuState.items}
viewportHeight={viewport.height}
groundOffset={config.padding.bottom}
companionPosition={renderedPosition}
companionSize={config.size}
onItemRelease={handleItemClick}
onItemLanded={handleItemLanded}
onItemUse={handleItemUse}
isItemOnCooldown={isItemOnCooldown}
/>
</div>
</>
);
}
@@ -0,0 +1,122 @@
/**
* OverstimulationBlockOverlay — Full-screen visual feedback when Blobbi is
* overstimulated. Zooms the UI toward Blobbi (#root transform), fires a
* radial shockwave, and holds a red vignette. Portaled to document.body
* so the overlay stays at viewport scale while #root is zoomed.
*/
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const SHOCKWAVE_MS = 600;
const VIGNETTE_IN_MS = 300;
const VIGNETTE_OUT_MS = 600;
const ZOOM = 5;
const ZOOM_IN_MS = 280;
const ZOOM_OUT_MS = 700;
interface Props {
isBlocked: boolean;
}
export function OverstimulationBlockOverlay({ isBlocked }: Props) {
const [visible, setVisible] = useState(false);
const [shockwave, setShockwave] = useState(false);
const wasBlocked = useRef(false);
const origin = useRef({ x: 0, y: 0 });
const savedScroll = useRef(0);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const root = document.getElementById('root');
if (!root) return;
if (isBlocked && !wasBlocked.current) {
// Save scroll position and snap to top so sticky nav bars and
// scroll-hidden elements don't end up in broken positions when
// the zoom transform creates a new containing block.
savedScroll.current = window.scrollY;
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
// Find Blobbi's true visual center via DOM query (after scroll reset)
const el = document.querySelector<HTMLElement>('[data-blobbi-companion]');
const rect = el?.getBoundingClientRect();
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
origin.current = { x: cx, y: cy };
root.style.transformOrigin = `${cx}px ${cy}px`;
root.style.transition = `transform ${ZOOM_IN_MS}ms cubic-bezier(0.22,1,0.36,1)`;
root.style.transform = `scale(${ZOOM})`;
document.body.style.overflow = 'hidden';
if (fadeTimer.current) { clearTimeout(fadeTimer.current); fadeTimer.current = null; }
setVisible(true);
setShockwave(true);
} else if (!isBlocked && wasBlocked.current) {
root.style.transition = `transform ${ZOOM_OUT_MS}ms cubic-bezier(0.16,1,0.3,1)`;
root.style.transform = 'scale(1)';
setShockwave(false);
fadeTimer.current = setTimeout(() => {
root.style.transform = root.style.transformOrigin = root.style.transition = '';
document.body.style.overflow = '';
window.scrollTo({ top: savedScroll.current, behavior: 'instant' as ScrollBehavior });
setVisible(false);
fadeTimer.current = null;
}, Math.max(VIGNETTE_OUT_MS, ZOOM_OUT_MS));
}
wasBlocked.current = isBlocked;
}, [isBlocked]);
useEffect(() => () => {
const root = document.getElementById('root');
if (root) root.style.transform = root.style.transformOrigin = root.style.transition = '';
document.body.style.overflow = '';
if (savedScroll.current) window.scrollTo({ top: savedScroll.current, behavior: 'instant' as ScrollBehavior });
if (fadeTimer.current) clearTimeout(fadeTimer.current);
}, []);
useEffect(() => {
if (!shockwave) return;
const t = setTimeout(() => setShockwave(false), SHOCKWAVE_MS);
return () => clearTimeout(t);
}, [shockwave]);
if (!visible) return null;
const { x, y } = origin.current;
const css = { '--sx': `${x}px`, '--sy': `${y}px` } as React.CSSProperties;
return createPortal(
<>
<div
aria-hidden
style={{
position: 'fixed', inset: 0, zIndex: 99999,
pointerEvents: isBlocked ? 'all' : 'none',
background: `radial-gradient(ellipse 60% 60% at var(--sx) var(--sy),
transparent 0%, rgba(127,29,29,0.06) 40%, rgba(127,29,29,0.18) 70%, rgba(127,29,29,0.30) 100%)`,
animation: isBlocked
? `overstim-vignette-in ${VIGNETTE_IN_MS}ms ease-out forwards`
: `overstim-vignette-out ${VIGNETTE_OUT_MS}ms ease-in forwards`,
...css,
}}
/>
{shockwave && (
<div
aria-hidden
style={{
position: 'fixed', zIndex: 100000, pointerEvents: 'none', borderRadius: '50%',
left: `calc(var(--sx) - 50vmax)`, top: `calc(var(--sy) - 50vmax)`,
width: '100vmax', height: '100vmax',
background: `radial-gradient(circle at center,
transparent 30%, rgba(239,68,68,0.35) 45%, rgba(239,68,68,0.20) 55%, transparent 70%)`,
animation: `overstim-shockwave ${SHOCKWAVE_MS}ms ease-out forwards`,
...css,
}}
/>
)}
</>,
document.body,
);
}
@@ -0,0 +1,93 @@
/**
* Shake Detection — Detects vigorous shaking during drag via pointer samples.
*
* Framework-agnostic. Scores shake intensity based on speed, direction
* reversals, and cumulative energy in a sliding time window.
*/
import type { Position } from '../types/companion.types';
// ─── Config ───────────────────────────────────────────────────────────────────
const WINDOW_MS = 2000;
const MIN_INTERVAL_MS = 16; // ~60 samples/s max
const SPEED_THRESH = 400; // px/s for "vigorous"
const REVERSAL_THRESH = 3;
const MIN_ENERGY = 800;
// ─── Types ────────────────────────────────────────────────────────────────────
interface Sample { x: number; y: number; t: number }
export interface ShakeTracker {
samples: Sample[];
speedSum: number;
reversals: number;
lastDx: number;
lastDy: number;
hasDir: boolean;
energy: number;
}
export interface ShakeResult {
triggered: boolean;
intensity: number;
energy: number;
shakeDurationMs: number;
}
// ─── API ──────────────────────────────────────────────────────────────────────
export function createShakeTracker(): ShakeTracker {
return { samples: [], speedSum: 0, reversals: 0, lastDx: 0, lastDy: 0, hasDir: false, energy: 0 };
}
export function resetTracker(t: ShakeTracker): void {
t.samples.length = 0;
t.speedSum = t.reversals = t.lastDx = t.lastDy = t.energy = 0;
t.hasDir = false;
}
export function recordSample(t: ShakeTracker, pos: Position): void {
const now = performance.now();
const { samples } = t;
if (samples.length > 0 && now - samples[samples.length - 1]!.t < MIN_INTERVAL_MS) return;
samples.push({ x: pos.x, y: pos.y, t: now });
if (samples.length >= 2) {
const prev = samples[samples.length - 2]!;
const curr = samples[samples.length - 1]!;
const dt = (curr.t - prev.t) / 1000;
if (dt > 0) {
const dx = curr.x - prev.x, dy = curr.y - prev.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / dt;
t.speedSum += speed;
if (t.hasDir && dx * t.lastDx + dy * t.lastDy < 0) t.reversals++;
if (dist > 2) { t.lastDx = dx; t.lastDy = dy; t.hasDir = true; }
if (speed > SPEED_THRESH && t.reversals >= 1) t.energy += speed * dt;
}
}
const cutoff = now - WINDOW_MS;
while (samples.length > 0 && samples[0]!.t < cutoff) samples.shift();
}
export function computeShakeResult(t: ShakeTracker): ShakeResult {
const { samples, speedSum, reversals, energy } = t;
const n = samples.length;
const none: ShakeResult = { triggered: false, intensity: 0, energy: 0, shakeDurationMs: 0 };
if (n < 4) return none;
const avg = speedSum / (n - 1);
if (avg <= SPEED_THRESH || reversals < REVERSAL_THRESH || energy < MIN_ENERGY) return none;
const dur = samples[n - 1]!.t - samples[0]!.t;
const normEnergy = Math.min(1, (energy - MIN_ENERGY) / 4200);
const normReversals = Math.min(1, reversals / 20);
const intensity = Math.min(1, normEnergy * 0.7 + normReversals * 0.3);
return { triggered: true, intensity, energy, shakeDurationMs: dur };
}
@@ -0,0 +1,175 @@
/**
* useOverstimulationReaction — Blobbi gets angry from rapid repeated clicks.
*
* Phases: idle → rising → cooling → idle, or rising → blocked → cooling → idle.
* At max level, enters a timed block where clicks are ignored.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
// ─── Profile & Defaults ──────────────────────────────────────────────────────
export interface OverstimulationProfile {
mild: { recipe: BlobbiVisualRecipe; label: string };
strong: { recipe: BlobbiVisualRecipe; label: string };
fillColor: string;
}
export const ANGRY_PROFILE: OverstimulationProfile = {
mild: {
recipe: {
mouth: { droopyMouth: { widthScale: 0.85, curveScale: 0.25 } },
eyebrows: { config: { angle: 14, offsetY: -9, strokeWidth: 1.6, color: '#4b5563' } },
},
label: 'annoyed',
},
strong: {
recipe: {
mouth: { sadMouth: true },
eyebrows: { config: { angle: 22, offsetY: -9, strokeWidth: 2.2, color: '#374151' } },
},
label: 'furious',
},
fillColor: '#ef4444',
};
// ─── Constants ────────────────────────────────────────────────────────────────
const WINDOW_MS = 2000;
const ACTIVATION = 4;
const STRONG_LEVEL = 0.2;
const INCREMENT = 0.09;
const COOLDOWN_DELAY = 1500;
const DRAIN_RATE = 0.25;
const BLOCK_MIN = 2000;
const BLOCK_MAX = 4000;
const VIS_THRESH = 0.02;
// ─── Types ────────────────────────────────────────────────────────────────────
export type OverstimulationPhase = 'idle' | 'rising' | 'cooling' | 'blocked';
export interface UseOverstimulationReactionResult {
level: number;
phase: OverstimulationPhase;
isBlocked: boolean;
recipe: BlobbiVisualRecipe | null;
recipeLabel: string | null;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useOverstimulationReaction({
isActive,
profile = ANGRY_PROFILE,
}: {
isActive: boolean;
profile?: OverstimulationProfile;
}): UseOverstimulationReactionResult {
const [visLevel, setVisLevel] = useState(0);
const [phase, setPhase] = useState<OverstimulationPhase>('idle');
const lvl = useRef(0);
const ph = useRef<OverstimulationPhase>('idle');
const clicks = useRef<number[]>([]);
const lastClick = useRef(0);
const lastVis = useRef(0);
const raf = useRef<number | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevT = useRef(0);
const prof = useRef(profile);
prof.current = profile;
const stop = useCallback(() => { if (raf.current !== null) { cancelAnimationFrame(raf.current); raf.current = null; } }, []);
const clearTimer = useCallback(() => { if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; } }, []);
const push = useCallback((level: number, p: OverstimulationPhase) => {
const changed = p !== ph.current;
if (changed || Math.abs(level - lastVis.current) >= VIS_THRESH || (level === 0 && lastVis.current !== 0)) {
lastVis.current = level;
setVisLevel(level);
}
if (changed) { ph.current = p; setPhase(p); }
}, []);
// rAF loop — drains level during cooling, detects cooldown in rising
const startRef = useRef<() => void>(() => {});
const tick = useCallback((now: number) => {
const dt = prevT.current > 0 ? (now - prevT.current) / 1000 : 0;
prevT.current = now;
if (ph.current === 'cooling') {
const next = Math.max(0, lvl.current - DRAIN_RATE * dt);
lvl.current = next;
push(next, next <= 0 ? 'idle' : 'cooling');
if (next <= 0) { clicks.current.length = 0; stop(); return; }
} else if (ph.current === 'rising') {
if (now - lastClick.current >= COOLDOWN_DELAY) push(lvl.current, 'cooling');
} else { stop(); return; }
raf.current = requestAnimationFrame(tick);
}, [push, stop]);
const start = useCallback(() => {
if (raf.current !== null) return;
prevT.current = performance.now();
raf.current = requestAnimationFrame(tick);
}, [tick]);
startRef.current = start;
const enterBlocked = useCallback(() => {
push(1, 'blocked');
const dur = BLOCK_MIN + Math.random() * (BLOCK_MAX - BLOCK_MIN);
clearTimer();
timer.current = setTimeout(() => {
timer.current = null;
push(lvl.current, 'cooling');
startRef.current();
}, dur);
}, [push, clearTimer]);
// Global click handler
useEffect(() => {
if (!isActive) {
stop(); clearTimer(); clicks.current = [];
lvl.current = 0; lastVis.current = 0;
push(0, 'idle');
return;
}
const handler = () => {
if (ph.current === 'blocked') return;
const now = Date.now();
const c = clicks.current;
c.push(now);
const cutoff = now - WINDOW_MS;
while (c.length > 0 && c[0]! < cutoff) c.shift();
lastClick.current = performance.now();
if (c.length < ACTIVATION) return;
const next = Math.min(1, lvl.current + INCREMENT);
lvl.current = next;
if (next >= 1) { stop(); enterBlocked(); return; }
push(next, 'rising');
startRef.current();
};
document.addEventListener('pointerdown', handler, { passive: true });
return () => document.removeEventListener('pointerdown', handler);
}, [isActive, stop, clearTimer, push, enterBlocked]);
useEffect(() => () => { stop(); clearTimer(); }, [stop, clearTimer]);
// Resolve level → recipe
return useMemo((): UseOverstimulationReactionResult => {
if (phase === 'idle' || visLevel <= 0)
return { level: 0, phase: 'idle', isBlocked: false, recipe: null, recipeLabel: null };
const p = prof.current;
const isBlocked = phase === 'blocked';
if (visLevel >= STRONG_LEVEL) {
const recipe: BlobbiVisualRecipe = {
...p.strong.recipe,
bodyEffects: { ...p.strong.recipe.bodyEffects, angerRise: { color: p.fillColor, duration: 0, level: visLevel } },
};
return { level: visLevel, phase, isBlocked, recipe, recipeLabel: p.strong.label };
}
return { level: visLevel, phase, isBlocked, recipe: p.mild.recipe, recipeLabel: p.mild.label };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, visLevel, profile]);
}
@@ -0,0 +1,188 @@
/**
* useShakeReaction — Blobbi gets dizzy (and optionally nauseous) when shaken.
*
* Phases: idle → shaking → dizzy → recovering → idle.
* Nausea (green body fill) only triggers when hunger >= 90.
*/
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 & Defaults ──────────────────────────────────────────────────────
export interface ShakeReactionProfile {
dizzy: { recipe: BlobbiVisualRecipe; label: string };
nauseated: { recipe: BlobbiVisualRecipe; label: string };
nauseaFillColor: string;
nauseaBottomOpacity?: number;
nauseaEdgeOpacity?: number;
}
const DIZZY_RECIPE = resolveVisualRecipe('dizzy');
export const DIZZY_NAUSEA_PROFILE: ShakeReactionProfile = {
dizzy: { recipe: DIZZY_RECIPE, label: 'dizzy' },
nauseated: {
recipe: {
...DIZZY_RECIPE,
mouth: { roundMouth: { rx: 5, ry: 6, filled: true } },
eyebrows: { config: { angle: -15, offsetY: -12, strokeWidth: 1.5, color: '#6b7280', curve: 0.15 } },
},
label: 'nauseated',
},
nauseaFillColor: '#4a7a3d',
nauseaBottomOpacity: 0.78,
nauseaEdgeOpacity: 0.65,
};
// ─── Constants ────────────────────────────────────────────────────────────────
const HUNGER_THRESH = 90;
const DIZZY_MIN_S = 3;
const DIZZY_MAX_S = 8;
const DRAIN_RATE = 0.25;
const VIS_THRESH = 0.02;
// ─── Types ────────────────────────────────────────────────────────────────────
export type ShakeReactionPhase = 'idle' | 'shaking' | 'dizzy' | 'recovering';
export interface UseShakeReactionResult {
phase: ShakeReactionPhase;
nauseaLevel: number;
recipe: BlobbiVisualRecipe | null;
recipeLabel: string | null;
onDragUpdate: (result: ShakeResult) => void;
onDragEnd: (result: ShakeResult) => void;
onDragStart: () => void;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useShakeReaction({
isActive, hunger, profile = DIZZY_NAUSEA_PROFILE,
}: {
isActive: boolean;
hunger: number;
profile?: ShakeReactionProfile;
}): UseShakeReactionResult {
const [visLevel, setVisLevel] = useState(0);
const [phase, setPhase] = useState<ShakeReactionPhase>('idle');
const lvl = useRef(0);
const ph = useRef<ShakeReactionPhase>('idle');
const lastVis = useRef(0);
const raf = useRef<number | null>(null);
const prevT = useRef(0);
const dizzyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hungerRef = useRef(hunger);
hungerRef.current = hunger;
const toasted = useRef(false);
const prof = useRef(profile);
prof.current = profile;
const stop = useCallback(() => { if (raf.current !== null) { cancelAnimationFrame(raf.current); raf.current = null; } }, []);
const clearDizzy = useCallback(() => { if (dizzyTimer.current !== null) { clearTimeout(dizzyTimer.current); dizzyTimer.current = null; } }, []);
const push = useCallback((level: number, p: ShakeReactionPhase) => {
const changed = p !== ph.current;
if (changed || Math.abs(level - lastVis.current) >= VIS_THRESH || (level === 0 && lastVis.current !== 0)) {
lastVis.current = level;
setVisLevel(level);
}
if (changed) { ph.current = p; setPhase(p); }
}, []);
// rAF drain loop — runs during dizzy + recovering
const tick = useCallback((now: number) => {
const dt = prevT.current > 0 ? (now - prevT.current) / 1000 : 0;
prevT.current = now;
const next = Math.max(0, lvl.current - DRAIN_RATE * dt);
lvl.current = next;
if (next <= 0) {
if (ph.current === 'recovering') { push(0, 'idle'); toasted.current = false; }
else push(0, ph.current); // dizzy: stay, timer handles transition
stop(); return;
}
push(next, ph.current);
raf.current = requestAnimationFrame(tick);
}, [push, stop]);
const startDrain = useCallback(() => {
if (raf.current !== null) return;
prevT.current = performance.now();
raf.current = requestAnimationFrame(tick);
}, [tick]);
const startRef = useRef(startDrain);
startRef.current = startDrain;
const resetAll = useCallback(() => {
stop(); clearDizzy(); lvl.current = 0; toasted.current = false;
push(0, 'idle');
}, [stop, clearDizzy, push]);
const onDragStart = useCallback(() => {
if (ph.current !== 'idle') resetAll();
toasted.current = false;
}, [resetAll]);
const onDragUpdate = useCallback((result: ShakeResult) => {
if (!result.triggered) return;
const sick = hungerRef.current >= HUNGER_THRESH;
const nausea = sick ? Math.min(1, result.intensity) : 0;
lvl.current = nausea;
if (ph.current === 'idle' || ph.current === 'shaking') {
push(nausea, 'shaking');
if (sick && !toasted.current) {
toasted.current = true;
toast({ title: 'Careful\u2026', description: 'Blobbi is feeling sick!' });
}
}
}, [push]);
const onDragEnd = useCallback((result: ShakeResult) => {
const wasShaking = ph.current === 'shaking';
if (!result.triggered && !wasShaking) return;
const intensity = result.triggered ? result.intensity : 0;
const dur = DIZZY_MIN_S + intensity * (DIZZY_MAX_S - DIZZY_MIN_S);
const sick = hungerRef.current >= HUNGER_THRESH;
const nausea = sick ? Math.min(1, intensity) : 0;
lvl.current = nausea;
push(nausea, 'dizzy');
if (nausea > 0) startRef.current();
clearDizzy();
dizzyTimer.current = setTimeout(() => {
dizzyTimer.current = null;
if (lvl.current > 0) push(lvl.current, 'recovering');
else { push(0, 'idle'); toasted.current = false; }
}, dur * 1000);
}, [push, clearDizzy]);
useEffect(() => { if (!isActive) resetAll(); }, [isActive, resetAll]);
useEffect(() => () => { stop(); clearDizzy(); }, [stop, clearDizzy]);
// Resolve phase + level → recipe
return useMemo((): UseShakeReactionResult => {
const base = { onDragUpdate, onDragEnd, onDragStart };
if (phase === 'idle')
return { ...base, phase: 'idle', nauseaLevel: 0, recipe: null, recipeLabel: null };
const p = prof.current;
if (visLevel > 0) {
const recipe: BlobbiVisualRecipe = {
...p.nauseated.recipe,
bodyEffects: {
...p.nauseated.recipe.bodyEffects,
angerRise: {
color: p.nauseaFillColor, duration: 0, level: visLevel,
bottomOpacity: p.nauseaBottomOpacity, edgeOpacity: p.nauseaEdgeOpacity,
},
},
};
return { ...base, phase, nauseaLevel: visLevel, recipe, recipeLabel: p.nauseated.label };
}
return { ...base, phase, nauseaLevel: 0, recipe: p.dizzy.recipe, recipeLabel: p.dizzy.label };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, visLevel, profile, onDragUpdate, onDragEnd, onDragStart]);
}
+12 -2
View File
@@ -21,7 +21,7 @@
* inside the SVG continue running across parent rerenders.
*/
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
@@ -31,6 +31,7 @@ import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from
import type { BlobbiEmotion } from './lib/emotion-types';
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
import { debugBlobbi } from './lib/debug';
import { useRecipeFingerprint, useFillLevelUpdate } from './hooks/useFillLevelUpdate';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
export interface BlobbiAdultSvgRendererProps {
@@ -70,6 +71,10 @@ export function BlobbiAdultSvgRenderer({
bodyEffects,
className,
}: BlobbiAdultSvgRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const recipeFingerprint = useRecipeFingerprint(recipeProp);
useFillLevelUpdate(containerRef, blobbi.id, recipeProp);
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild');
@@ -91,12 +96,17 @@ 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]);
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
+9 -2
View File
@@ -17,7 +17,7 @@
* - Companion runtime (drag, float, position)
*/
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
@@ -27,6 +27,7 @@ import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from
import type { BlobbiEmotion } from './lib/emotion-types';
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
import { debugBlobbi } from './lib/debug';
import { useRecipeFingerprint, useFillLevelUpdate } from './hooks/useFillLevelUpdate';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
export interface BlobbiBabySvgRendererProps {
@@ -66,6 +67,10 @@ export function BlobbiBabySvgRenderer({
bodyEffects,
className,
}: BlobbiBabySvgRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const recipeFingerprint = useRecipeFingerprint(recipeProp);
useFillLevelUpdate(containerRef, blobbi.id, recipeProp);
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild');
@@ -87,12 +92,14 @@ 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]);
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
+61
View File
@@ -0,0 +1,61 @@
/**
* useFillLevelUpdate — Shared hook for SVG renderers to:
* 1. Compute a structural recipe fingerprint that excludes angerRise.level
* (so level-only changes don't trigger full SVG rebuilds)
* 2. Imperatively update gradient stops when level changes
* (preserves SMIL animations that dangerouslySetInnerHTML would kill)
*/
import { useMemo, useEffect, type RefObject } from 'react';
import type { BlobbiVisualRecipe } from '../lib/recipe';
/** Feather zone matching generateAngerRiseEffect() in generators.ts. */
const FEATHER = 0.10;
/**
* Compute a stable fingerprint from a recipe that ignores angerRise.level.
* Returns an empty string when recipe is null/undefined.
*/
export function useRecipeFingerprint(recipe: BlobbiVisualRecipe | undefined): string {
return useMemo(() => {
if (!recipe) return '';
const { bodyEffects, ...rest } = recipe;
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 },
});
}, [recipe]);
}
/**
* Imperatively update the anger-rise gradient stops when only the level
* changes, avoiding a full SVG rebuild.
*/
export function useFillLevelUpdate(
containerRef: RefObject<HTMLDivElement | null>,
blobbiId: string,
recipe: BlobbiVisualRecipe | undefined,
): void {
const fillLevel = recipe?.bodyEffects?.angerRise?.level;
useEffect(() => {
const container = containerRef.current;
if (!container || fillLevel === undefined) return;
const gradientId = `blobbi-anger-gradient-${blobbiId}`;
const gradient = container.querySelector(`#${CSS.escape(gradientId)}`);
if (!gradient) return;
const stops = gradient.querySelectorAll('stop');
if (stops.length < 3) return;
const edgeOffset = Math.max(0, fillLevel - FEATHER);
stops[1]?.setAttribute('offset', String(edgeOffset));
stops[2]?.setAttribute('offset', String(fillLevel));
}, [fillLevel, blobbiId, containerRef]);
}
+3
View File
@@ -76,6 +76,9 @@ export function applyBodyEffects(svgText: string, spec: BodyEffectsSpec): string
type: 'anger-rise',
color: spec.angerRise.color,
duration: spec.angerRise.duration,
level: spec.angerRise.level,
bottomOpacity: spec.angerRise.bottomOpacity,
edgeOpacity: spec.angerRise.edgeOpacity,
},
idSuffix,
);
+65 -7
View File
@@ -34,6 +34,25 @@ import type {
BodyPathInfo,
} from './types';
// ─── SVG Color Sanitization ───────────────────────────────────────────────────
/** Hex color: #rgb, #rrggbb, or #rrggbbaa */
const HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
/** Named CSS color or functional notation (rgb/rgba/hsl/hsla with digits, commas, spaces, dots, %) */
const FUNC_RE = /^(?:rgb|rgba|hsl|hsla)\(\s*[\d.,\s%]+\)$/i;
const NAMED_RE = /^[a-z]{3,24}$/i;
/**
* Validate a color value before interpolating it into SVG markup.
* Returns the color unchanged if it looks safe, otherwise returns a
* harmless fallback. This prevents SVG injection if a future caller
* ever passes user-controlled color strings.
*/
function sanitizeSvgColor(color: string, fallback = '#000'): string {
if (HEX_RE.test(color) || FUNC_RE.test(color) || NAMED_RE.test(color)) return color;
return fallback;
}
// ─── Body Path Detection ──────────────────────────────────────────────────────
/**
@@ -725,19 +744,51 @@ 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 safeColor = sanitizeSvgColor(config.color);
const suffix = idSuffix ?? Math.random().toString(36).slice(2, 8);
const clipId = `blobbi-anger-clip-${suffix}`;
const gradientId = `blobbi-anger-gradient-${suffix}`;
const defs = `
// 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, 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;
// 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
? `
<clipPath id="${clipId}">
<path d="${pathD}" />
</clipPath>
<linearGradient id="${gradientId}" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="${config.color}">
<stop offset="0%" stop-color="${safeColor}" stop-opacity="${bottomOpacity}" />
<stop offset="${Math.max(0, lvl - feather)}" stop-color="${safeColor}" stop-opacity="${edgeOpacity}" />
<stop offset="${lvl}" stop-color="${safeColor}" stop-opacity="0" />
</linearGradient>`
: `
<clipPath id="${clipId}">
<path d="${pathD}" />
</clipPath>
<linearGradient id="${gradientId}" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="${safeColor}">
<animate
attributeName="stop-opacity"
values="0;0.5;0.5"
@@ -746,7 +797,7 @@ export function generateAngerRiseEffect(
fill="freeze"
/>
</stop>
<stop stop-color="${config.color}">
<stop stop-color="${safeColor}">
<animate
attributeName="offset"
values="0;1"
@@ -761,7 +812,7 @@ export function generateAngerRiseEffect(
fill="freeze"
/>
</stop>
<stop stop-color="${config.color}" stop-opacity="0">
<stop stop-color="${safeColor}" stop-opacity="0">
<animate
attributeName="offset"
values="0;1"
@@ -771,11 +822,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})"
/>`;
+20 -1
View File
@@ -71,6 +71,25 @@ export interface BodyEffectConfig {
color: string;
/** Animation duration in seconds */
duration: number;
/**
* Static fill level (01). When provided, the gradient is rendered at
* this fixed offset instead of using the SMIL rise animation. This
* enables external systems (e.g. overstimulation) to control exactly
* 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;
}
/**
@@ -83,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 };
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 -5
View File
@@ -144,7 +144,7 @@ export interface BodyEffectsRecipe {
/** Stink cloud puffs */
stinkClouds?: StinkCloudsConfig;
/** Anger-rise color overlay */
angerRise?: { color: string; duration: number };
angerRise?: { color: string; duration: number; level?: number; bottomOpacity?: number; edgeOpacity?: number };
}
/**
@@ -953,10 +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,
};
bodySpec.angerRise = recipe.bodyEffects.angerRise;
}
if (instanceId) {
bodySpec.idPrefix = instanceId;
+18
View File
@@ -726,3 +726,21 @@
50% { transform: translateX(3px); }
}
/* ── Overstimulation block overlay ─────────────────────────────────────────── */
@keyframes overstim-vignette-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overstim-vignette-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes overstim-shockwave {
0% { transform: scale(0.1); opacity: 1; }
60% { opacity: 0.6; }
100% { transform: scale(2.2); opacity: 0; }
}