Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c70528f5b0 | |||
| a0c111986b | |||
| 88d5b5844a |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.6",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.6",
|
||||
"version": "2.7.0",
|
||||
"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). */
|
||||
@@ -94,6 +96,7 @@ export function BlobbiCompanion({
|
||||
onUpdateDrag,
|
||||
onEndDrag,
|
||||
onClick,
|
||||
isClickBlocked = false,
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion,
|
||||
@@ -105,9 +108,11 @@ export function BlobbiCompanion({
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useCallback, useState, useMemo } from 'react';
|
||||
import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion';
|
||||
import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction';
|
||||
import { useActionEmotionOverride } from '../hooks/useActionEmotionOverride';
|
||||
import { useOverstimulationReaction } from '../hooks/useOverstimulationReaction';
|
||||
import { BlobbiCompanion } from './BlobbiCompanion';
|
||||
import { DebugGroundOverlay } from './DebugGroundOverlay';
|
||||
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
|
||||
@@ -136,6 +137,15 @@ export function BlobbiCompanionLayer() {
|
||||
|
||||
const { actionOverride, triggerOverride } = useActionEmotionOverride();
|
||||
|
||||
// ── Overstimulation reaction ───────────────────────────────────────────────
|
||||
const {
|
||||
recipe: overstimRecipe,
|
||||
recipeLabel: overstimLabel,
|
||||
isBlocked: isOverstimBlocked,
|
||||
} = useOverstimulationReaction({
|
||||
isActive: isVisible && !isEntering,
|
||||
});
|
||||
|
||||
const handleItemUse = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => {
|
||||
const action = CATEGORY_TO_ACTION[item.category];
|
||||
|
||||
@@ -237,13 +247,24 @@ 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. Action override (item use: feed → happy, etc.)
|
||||
// 4. 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 {
|
||||
companionRecipe = statusRecipe;
|
||||
companionRecipeLabel = statusRecipeLabel;
|
||||
}
|
||||
|
||||
// ── Early return ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -288,6 +309,7 @@ export function BlobbiCompanionLayer() {
|
||||
onUpdateDrag={updateDrag}
|
||||
onEndDrag={endDrag}
|
||||
onClick={handleCompanionClick}
|
||||
isClickBlocked={isOverstimBlocked}
|
||||
recipe={companionRecipe}
|
||||
recipeLabel={companionRecipeLabel}
|
||||
onPositionUpdate={handlePositionUpdate}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* useOverstimulationReaction — Blobbi reacts to rapid repeated clicks.
|
||||
*
|
||||
* Tracks global pointer-down events in a sliding time window. When the
|
||||
* click count crosses a threshold, the overstimulation level starts rising.
|
||||
* Additional clicks push the level higher. When clicks stop, the level
|
||||
* cools back down gradually.
|
||||
*
|
||||
* The visual output is determined by an **OverstimulationProfile** that maps
|
||||
* level ranges to visual recipes. The default profile produces angry
|
||||
* expressions, but future personalities can supply different profiles
|
||||
* (e.g. confused, nervous) without changing any of the core logic.
|
||||
*
|
||||
* Escalation timeline (with default tuning):
|
||||
* - 4 rapid clicks → mild angry face (level > 0)
|
||||
* - 6 rapid clicks → red body fill begins rising (level crosses 0.2)
|
||||
* - 15 rapid clicks → max level, Blobbi blocks clicks for 2–4 s
|
||||
* - After block ends → level cools naturally back to zero
|
||||
*
|
||||
* Cooling timeline:
|
||||
* - 1.5 s after last click → cooling phase starts
|
||||
* - ~4 s to drain from full (1.0) to zero at 0.25/s
|
||||
* - Total recovery from max: ~5.5 s
|
||||
*
|
||||
* Phases:
|
||||
* - idle: level = 0, no reaction active
|
||||
* - rising: user is clicking, level increasing
|
||||
* - cooling: clicks stopped, level decreasing gradually
|
||||
* - blocked: level reached max, Blobbi ignores clicks temporarily
|
||||
*
|
||||
* Performance: the real level lives in a ref and updates via rAF.
|
||||
* A visible-level state is only pushed when the delta exceeds a threshold,
|
||||
* yielding ~6–10 visual updates per second during transitions.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
|
||||
// ─── Profile System ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps overstimulation level ranges to visual recipes.
|
||||
*
|
||||
* Future personalities supply different profiles to produce different
|
||||
* expressions (confused, nervous, etc.) from the same level/phase logic.
|
||||
*/
|
||||
export interface OverstimulationProfile {
|
||||
/** Recipe when level crosses the mild threshold (face only). */
|
||||
mild: {
|
||||
recipe: BlobbiVisualRecipe;
|
||||
label: string;
|
||||
};
|
||||
/** Recipe when level crosses the strong threshold (face + body effect). */
|
||||
strong: {
|
||||
recipe: BlobbiVisualRecipe;
|
||||
label: string;
|
||||
};
|
||||
/** Color used for the body fill effect at the strong tier. */
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
/** Mildly annoyed: furrowed brows, slight frown, no body effects. */
|
||||
const ANNOYED_RECIPE: BlobbiVisualRecipe = {
|
||||
mouth: { droopyMouth: { widthScale: 0.85, curveScale: 0.25 } },
|
||||
eyebrows: {
|
||||
config: { angle: 14, offsetY: -9, strokeWidth: 1.6, color: '#4b5563' },
|
||||
},
|
||||
};
|
||||
|
||||
/** Very annoyed: angry brows, sad mouth. Body fill is added dynamically. */
|
||||
const FURIOUS_RECIPE: BlobbiVisualRecipe = {
|
||||
mouth: { sadMouth: true },
|
||||
eyebrows: {
|
||||
config: { angle: 22, offsetY: -9, strokeWidth: 2.2, color: '#374151' },
|
||||
},
|
||||
};
|
||||
|
||||
/** Default profile: angry personality. */
|
||||
export const ANGRY_PROFILE: OverstimulationProfile = {
|
||||
mild: { recipe: ANNOYED_RECIPE, label: 'annoyed' },
|
||||
strong: { recipe: FURIOUS_RECIPE, label: 'furious' },
|
||||
fillColor: '#ef4444',
|
||||
};
|
||||
|
||||
// ─── Thresholds & Timing ──────────────────────────────────────────────────────
|
||||
|
||||
/** Sliding window for counting clicks (ms). */
|
||||
const WINDOW_MS = 2000;
|
||||
|
||||
/** Clicks in the sliding window to trigger the mild reaction. Click #4 is the
|
||||
* first to increment the level, so the angry face appears on the 4th rapid click. */
|
||||
const ACTIVATION_THRESHOLD = 4;
|
||||
|
||||
/** Level at which the strong tier activates (face + red body fill).
|
||||
* With CLICK_INCREMENT = 0.09, this is crossed on the 6th rapid click (level 0.27). */
|
||||
const STRONG_LEVEL = 0.2;
|
||||
|
||||
/** Level added per click above the activation threshold.
|
||||
* 11 clicks past threshold (15 total) reach 0.99 → clamped to 1.0 → blocked. */
|
||||
const CLICK_INCREMENT = 0.09;
|
||||
|
||||
/** Milliseconds of no clicks before the cooling phase begins. */
|
||||
const COOLDOWN_DELAY_MS = 1500;
|
||||
|
||||
/** Level units drained per second during cooling.
|
||||
* Full-to-zero takes ~4 s. Combined with the 1.5 s delay, total recovery is ~5.5 s. */
|
||||
const COOLING_RATE = 0.25;
|
||||
|
||||
/** Minimum blocked duration (ms). */
|
||||
const BLOCK_MIN_MS = 2000;
|
||||
/** Maximum blocked duration (ms). */
|
||||
const BLOCK_MAX_MS = 4000;
|
||||
|
||||
/**
|
||||
* Minimum delta in visible level before pushing a React state update.
|
||||
* ~50 visual steps from 0→1 keeps renders at ~6–10fps during transitions.
|
||||
*/
|
||||
const VISIBLE_LEVEL_THRESHOLD = 0.02;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OverstimulationPhase = 'idle' | 'rising' | 'cooling' | 'blocked';
|
||||
|
||||
export interface UseOverstimulationReactionOptions {
|
||||
/** Whether the hook should listen for clicks. */
|
||||
isActive: boolean;
|
||||
/** Visual profile. Defaults to ANGRY_PROFILE. */
|
||||
profile?: OverstimulationProfile;
|
||||
}
|
||||
|
||||
export interface UseOverstimulationReactionResult {
|
||||
/** Current overstimulation level (0–1), throttled for rendering. */
|
||||
level: number;
|
||||
/** Current phase. */
|
||||
phase: OverstimulationPhase;
|
||||
/** Whether Blobbi clicks should be blocked. */
|
||||
isBlocked: boolean;
|
||||
/** Visual recipe override, or null when idle. */
|
||||
recipe: BlobbiVisualRecipe | null;
|
||||
/** Human-readable label for the recipe. */
|
||||
recipeLabel: string | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useOverstimulationReaction({
|
||||
isActive,
|
||||
profile = ANGRY_PROFILE,
|
||||
}: UseOverstimulationReactionOptions): UseOverstimulationReactionResult {
|
||||
// ── Visible state (throttled) ──
|
||||
const [visibleLevel, setVisibleLevel] = useState(0);
|
||||
const [phase, setPhase] = useState<OverstimulationPhase>('idle');
|
||||
|
||||
// ── Refs for high-frequency data ──
|
||||
const levelRef = useRef(0);
|
||||
const phaseRef = useRef<OverstimulationPhase>('idle');
|
||||
const clicksRef = useRef<number[]>([]);
|
||||
const lastClickRef = useRef(0);
|
||||
const lastVisibleRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const blockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const toastShownRef = useRef(false);
|
||||
const prevTimeRef = useRef(0);
|
||||
|
||||
// Keep profile in a ref so the rAF loop doesn't need it as a dep
|
||||
const profileRef = useRef(profile);
|
||||
profileRef.current = profile;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const clearRaf = useCallback(() => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearBlockTimer = useCallback(() => {
|
||||
if (blockTimerRef.current !== null) {
|
||||
clearTimeout(blockTimerRef.current);
|
||||
blockTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Push visible state if the delta is meaningful.
|
||||
*
|
||||
* This is the **single owner** of phase transitions — callers must NOT
|
||||
* mutate phaseRef before calling pushVisible. The function compares the
|
||||
* requested phase `p` against the current ref, commits the ref write,
|
||||
* and then sets the React state.
|
||||
*/
|
||||
const pushVisible = useCallback((level: number, p: OverstimulationPhase) => {
|
||||
const delta = Math.abs(level - lastVisibleRef.current);
|
||||
const phaseChanged = p !== phaseRef.current;
|
||||
// Always push on phase change, or snap to 0 at idle
|
||||
if (phaseChanged || delta >= VISIBLE_LEVEL_THRESHOLD || (level === 0 && lastVisibleRef.current !== 0)) {
|
||||
lastVisibleRef.current = level;
|
||||
setVisibleLevel(level);
|
||||
}
|
||||
if (phaseChanged) {
|
||||
phaseRef.current = p;
|
||||
setPhase(p);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── rAF cooling loop ──
|
||||
|
||||
const startRafLoopRef = useRef<() => void>(() => {});
|
||||
|
||||
const rafTick = useCallback((now: number) => {
|
||||
const dt = prevTimeRef.current > 0 ? (now - prevTimeRef.current) / 1000 : 0;
|
||||
prevTimeRef.current = now;
|
||||
|
||||
const currentPhase = phaseRef.current;
|
||||
|
||||
if (currentPhase === 'cooling') {
|
||||
const newLevel = Math.max(0, levelRef.current - COOLING_RATE * dt);
|
||||
levelRef.current = newLevel;
|
||||
pushVisible(newLevel, newLevel <= 0 ? 'idle' : 'cooling');
|
||||
|
||||
if (newLevel <= 0) {
|
||||
levelRef.current = 0;
|
||||
phaseRef.current = 'idle';
|
||||
toastShownRef.current = false;
|
||||
clearRaf();
|
||||
return;
|
||||
}
|
||||
} else if (currentPhase === 'rising') {
|
||||
// In rising, we just keep the loop alive to detect cooldown transition.
|
||||
// pushVisible owns the phase transition — do NOT mutate phaseRef here.
|
||||
const elapsed = now - lastClickRef.current;
|
||||
if (elapsed >= COOLDOWN_DELAY_MS) {
|
||||
pushVisible(levelRef.current, 'cooling');
|
||||
}
|
||||
} else {
|
||||
// idle or blocked — stop the loop
|
||||
clearRaf();
|
||||
return;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(rafTick);
|
||||
}, [pushVisible, clearRaf]);
|
||||
|
||||
const startRafLoop = useCallback(() => {
|
||||
if (rafRef.current !== null) return; // already running
|
||||
prevTimeRef.current = performance.now();
|
||||
rafRef.current = requestAnimationFrame(rafTick);
|
||||
}, [rafTick]);
|
||||
|
||||
startRafLoopRef.current = startRafLoop;
|
||||
|
||||
/** Enter blocked state. Uses startRafLoopRef to avoid circular dependency. */
|
||||
const enterBlocked = useCallback(() => {
|
||||
phaseRef.current = 'blocked';
|
||||
setPhase('blocked');
|
||||
|
||||
if (!toastShownRef.current) {
|
||||
toastShownRef.current = true;
|
||||
toast({
|
||||
title: 'Too many clicks!',
|
||||
description: 'Blobbi is overwhelmed and blocked clicks for a few seconds.',
|
||||
});
|
||||
}
|
||||
|
||||
const duration = BLOCK_MIN_MS + Math.random() * (BLOCK_MAX_MS - BLOCK_MIN_MS);
|
||||
|
||||
clearBlockTimer();
|
||||
blockTimerRef.current = setTimeout(() => {
|
||||
blockTimerRef.current = null;
|
||||
phaseRef.current = 'cooling';
|
||||
setPhase('cooling');
|
||||
prevTimeRef.current = performance.now();
|
||||
startRafLoopRef.current();
|
||||
}, duration);
|
||||
}, [clearBlockTimer]);
|
||||
|
||||
// ── Global click handler ──
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
// Reset everything
|
||||
clearRaf();
|
||||
clearBlockTimer();
|
||||
clicksRef.current = [];
|
||||
levelRef.current = 0;
|
||||
phaseRef.current = 'idle';
|
||||
lastVisibleRef.current = 0;
|
||||
toastShownRef.current = false;
|
||||
setVisibleLevel(0);
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = () => {
|
||||
const now = Date.now();
|
||||
const clicks = clicksRef.current;
|
||||
|
||||
// Don't process clicks during blocked phase
|
||||
if (phaseRef.current === 'blocked') return;
|
||||
|
||||
// Add timestamp and prune outside window
|
||||
clicks.push(now);
|
||||
const cutoff = now - WINDOW_MS;
|
||||
while (clicks.length > 0 && clicks[0]! < cutoff) {
|
||||
clicks.shift();
|
||||
}
|
||||
|
||||
lastClickRef.current = performance.now();
|
||||
|
||||
const count = clicks.length;
|
||||
|
||||
if (count < ACTIVATION_THRESHOLD) {
|
||||
// Below threshold — if we were rising/cooling, stay in that phase
|
||||
// (additional slow clicks don't cancel an ongoing reaction)
|
||||
return;
|
||||
}
|
||||
|
||||
// Above threshold: increase level
|
||||
const newLevel = Math.min(1, levelRef.current + CLICK_INCREMENT);
|
||||
levelRef.current = newLevel;
|
||||
|
||||
if (newLevel >= 1) {
|
||||
// Max reached — enter blocked
|
||||
clearRaf();
|
||||
pushVisible(1, 'blocked');
|
||||
enterBlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rising — pushVisible owns the phase transition, do NOT mutate phaseRef here
|
||||
pushVisible(newLevel, 'rising');
|
||||
|
||||
// Ensure rAF loop is running (for cooldown-delay detection)
|
||||
startRafLoopRef.current();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
}, [isActive, clearRaf, clearBlockTimer, pushVisible, enterBlocked]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearRaf();
|
||||
clearBlockTimer();
|
||||
};
|
||||
}, [clearRaf, clearBlockTimer]);
|
||||
|
||||
// ── Resolve level + phase → recipe ──
|
||||
|
||||
const result = useMemo((): UseOverstimulationReactionResult => {
|
||||
if (phase === 'idle' || visibleLevel <= 0) {
|
||||
return { level: 0, phase: 'idle', isBlocked: false, recipe: null, recipeLabel: null };
|
||||
}
|
||||
|
||||
const isBlocked = phase === 'blocked';
|
||||
const p = profileRef.current;
|
||||
|
||||
if (visibleLevel >= STRONG_LEVEL) {
|
||||
// Strong tier: face recipe + level-controlled body fill
|
||||
const recipe: BlobbiVisualRecipe = {
|
||||
...p.strong.recipe,
|
||||
bodyEffects: {
|
||||
...p.strong.recipe.bodyEffects,
|
||||
angerRise: { color: p.fillColor, duration: 0, level: visibleLevel },
|
||||
},
|
||||
};
|
||||
return { level: visibleLevel, phase, isBlocked, recipe, recipeLabel: p.strong.label };
|
||||
}
|
||||
|
||||
// Mild tier: face only
|
||||
return { level: visibleLevel, phase, isBlocked, recipe: p.mild.recipe, recipeLabel: p.mild.label };
|
||||
// profile must be in deps to recompute recipes when profile changes at runtime,
|
||||
// even though profileRef.current is read inside (ref is stale-safe, dep triggers recompute).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, visibleLevel, profile]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export function applyBodyEffects(svgText: string, spec: BodyEffectsSpec): string
|
||||
type: 'anger-rise',
|
||||
color: spec.angerRise.color,
|
||||
duration: spec.angerRise.duration,
|
||||
level: spec.angerRise.level,
|
||||
},
|
||||
idSuffix,
|
||||
);
|
||||
|
||||
@@ -632,7 +632,26 @@ export function generateAngerRiseEffect(
|
||||
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) control exact fill height each frame.
|
||||
const useStaticLevel = config.level !== undefined && config.level !== null;
|
||||
const lvl = useStaticLevel ? Math.max(0, Math.min(1, config.level!)) : 0;
|
||||
|
||||
// Feather zone: percentage of the gradient used for the soft top edge
|
||||
const feather = 0.08;
|
||||
|
||||
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-opacity="${0.5 * lvl}" />
|
||||
<stop offset="${Math.max(0, lvl - feather)}" stop-color="${config.color}" stop-opacity="${0.4 * lvl}" />
|
||||
<stop offset="${lvl}" stop-color="${config.color}" stop-opacity="0" />
|
||||
</linearGradient>`
|
||||
: `
|
||||
<clipPath id="${clipId}">
|
||||
<path d="${pathD}" />
|
||||
</clipPath>
|
||||
|
||||
@@ -71,6 +71,13 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +90,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 };
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -956,6 +956,7 @@ export function applyVisualRecipe(
|
||||
bodySpec.angerRise = {
|
||||
color: recipe.bodyEffects.angerRise.color,
|
||||
duration: recipe.bodyEffects.angerRise.duration,
|
||||
level: recipe.bodyEffects.angerRise.level,
|
||||
};
|
||||
}
|
||||
if (instanceId) {
|
||||
|
||||
Reference in New Issue
Block a user