diff --git a/package-lock.json b/package-lock.json index 98d45df6..b29c0388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/blobbi/companion/components/BlobbiCompanion.tsx b/src/blobbi/companion/components/BlobbiCompanion.tsx index 6568d189..aef8ebd0 100644 --- a/src/blobbi/companion/components/BlobbiCompanion.tsx +++ b/src/blobbi/companion/components/BlobbiCompanion.tsx @@ -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(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 (
{ + 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 ( - + ); } diff --git a/src/blobbi/companion/components/OverstimulationBlockOverlay.tsx b/src/blobbi/companion/components/OverstimulationBlockOverlay.tsx new file mode 100644 index 00000000..7f2e0e16 --- /dev/null +++ b/src/blobbi/companion/components/OverstimulationBlockOverlay.tsx @@ -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 | 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('[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( + <> +
+ {shockwave && ( +
+ )} + , + document.body, + ); +} diff --git a/src/blobbi/companion/core/shakeDetection.ts b/src/blobbi/companion/core/shakeDetection.ts new file mode 100644 index 00000000..632a751e --- /dev/null +++ b/src/blobbi/companion/core/shakeDetection.ts @@ -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 }; +} diff --git a/src/blobbi/companion/hooks/useOverstimulationReaction.ts b/src/blobbi/companion/hooks/useOverstimulationReaction.ts new file mode 100644 index 00000000..95b68c8c --- /dev/null +++ b/src/blobbi/companion/hooks/useOverstimulationReaction.ts @@ -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('idle'); + + const lvl = useRef(0); + const ph = useRef('idle'); + const clicks = useRef([]); + const lastClick = useRef(0); + const lastVis = useRef(0); + const raf = useRef(null); + const timer = useRef | 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]); +} diff --git a/src/blobbi/companion/hooks/useShakeReaction.ts b/src/blobbi/companion/hooks/useShakeReaction.ts new file mode 100644 index 00000000..c2f775bf --- /dev/null +++ b/src/blobbi/companion/hooks/useShakeReaction.ts @@ -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('idle'); + + const lvl = useRef(0); + const ph = useRef('idle'); + const lastVis = useRef(0); + const raf = useRef(null); + const prevT = useRef(0); + const dizzyTimer = useRef | 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]); +} diff --git a/src/blobbi/ui/BlobbiAdultSvgRenderer.tsx b/src/blobbi/ui/BlobbiAdultSvgRenderer.tsx index a9ce300b..da7755f1 100644 --- a/src/blobbi/ui/BlobbiAdultSvgRenderer.tsx +++ b/src/blobbi/ui/BlobbiAdultSvgRenderer.tsx @@ -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(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 (
diff --git a/src/blobbi/ui/BlobbiBabySvgRenderer.tsx b/src/blobbi/ui/BlobbiBabySvgRenderer.tsx index 8df0ffe6..1eb22b38 100644 --- a/src/blobbi/ui/BlobbiBabySvgRenderer.tsx +++ b/src/blobbi/ui/BlobbiBabySvgRenderer.tsx @@ -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(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 (
diff --git a/src/blobbi/ui/hooks/useFillLevelUpdate.ts b/src/blobbi/ui/hooks/useFillLevelUpdate.ts new file mode 100644 index 00000000..791d079a --- /dev/null +++ b/src/blobbi/ui/hooks/useFillLevelUpdate.ts @@ -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, + 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]); +} diff --git a/src/blobbi/ui/lib/bodyEffects/apply.ts b/src/blobbi/ui/lib/bodyEffects/apply.ts index 3247fbd4..09de678f 100644 --- a/src/blobbi/ui/lib/bodyEffects/apply.ts +++ b/src/blobbi/ui/lib/bodyEffects/apply.ts @@ -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, ); diff --git a/src/blobbi/ui/lib/bodyEffects/generators.ts b/src/blobbi/ui/lib/bodyEffects/generators.ts index 948a18ef..a1be645b 100644 --- a/src/blobbi/ui/lib/bodyEffects/generators.ts +++ b/src/blobbi/ui/lib/bodyEffects/generators.ts @@ -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 (0–1) + // 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 + ? ` - + + + + ` + : ` + + + + + - + - + `; + // 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 = ` `; diff --git a/src/blobbi/ui/lib/bodyEffects/types.ts b/src/blobbi/ui/lib/bodyEffects/types.ts index 02ad1d0d..69aa4ab4 100644 --- a/src/blobbi/ui/lib/bodyEffects/types.ts +++ b/src/blobbi/ui/lib/bodyEffects/types.ts @@ -71,6 +71,25 @@ export interface BodyEffectConfig { color: string; /** Animation duration in seconds */ duration: number; + /** + * Static fill level (0–1). 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 (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; } /** @@ -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. diff --git a/src/blobbi/ui/lib/recipe.ts b/src/blobbi/ui/lib/recipe.ts index 91130a7d..f26ba679 100644 --- a/src/blobbi/ui/lib/recipe.ts +++ b/src/blobbi/ui/lib/recipe.ts @@ -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; diff --git a/src/index.css b/src/index.css index a2517d46..0ab93c9a 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } +} +