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:
Generated
+2
-2
@@ -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]);
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
? `
|
||||
<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})"
|
||||
/>`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user