fix(blobbi): body-aware dirt placement, dizzy-style spirals, stronger front dust
## Adult dirt placement now uses real body silhouette: - detectBodyPath() extracts full X/Y bounds from SVG path - computeAdultDirtPositions() places marks relative to actual body - Dirt at lower 35% of body height, near side edges - Scales with body size (mark length = 5% of width) - Fallback to conservative defaults if body not detected ## Egg spirals now match dizzy eye visual language: - Uses same createSpiralPath() Archimedean spiral algorithm - SVG-native animateTransform rotation (not CSS) - Dark stroke color (#1f2937) matching dizzy eyes - Positioned floating around egg, not inside shell - Varying sizes and rotation speeds for visual interest ## Front/back dust distribution: - Egg: 2 back particles below, 4 front particles at lower edges - Baby: 3 back below body, 3 front at lower side edges - Adult: 3 back below body, 3 front at lower edges (body-aware) - Front dust: larger, higher opacity (0.75-0.8), darker color - Back dust: smaller, lower opacity (0.55), lighter color - All dust avoids face region, stays at lower body edges
This commit is contained in:
@@ -758,16 +758,17 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
className="animate-egg-dust-particle"
|
||||
/>
|
||||
</div>
|
||||
{/* Front-layer dust - ONLY at lower-left and lower-right edges */}
|
||||
{/* Lower-left edge particle */}
|
||||
{/* Front-layer dust - at lower-left and lower-right edges
|
||||
More visible than back layer, stronger colors */}
|
||||
{/* Lower-left edge particle - larger, more visible */}
|
||||
<div
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '12%',
|
||||
left: '8%',
|
||||
width: '0.22em',
|
||||
height: '0.22em',
|
||||
background: 'rgba(68, 64, 60, 0.7)',
|
||||
left: '6%',
|
||||
width: '0.28em',
|
||||
height: '0.28em',
|
||||
background: 'rgba(63, 63, 70, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '0.1s',
|
||||
@@ -778,10 +779,10 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '15%',
|
||||
right: '6%',
|
||||
width: '0.2em',
|
||||
height: '0.2em',
|
||||
background: 'rgba(68, 64, 60, 0.65)',
|
||||
right: '5%',
|
||||
width: '0.25em',
|
||||
height: '0.25em',
|
||||
background: 'rgba(63, 63, 70, 0.75)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '0.5s',
|
||||
@@ -792,82 +793,136 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '5%',
|
||||
left: '15%',
|
||||
width: '0.18em',
|
||||
height: '0.18em',
|
||||
background: 'rgba(87, 83, 78, 0.6)',
|
||||
left: '18%',
|
||||
width: '0.22em',
|
||||
height: '0.22em',
|
||||
background: 'rgba(68, 64, 60, 0.7)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '0.8s',
|
||||
}}
|
||||
/>
|
||||
{/* Very bottom right particle */}
|
||||
<div
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '3%',
|
||||
right: '20%',
|
||||
width: '0.2em',
|
||||
height: '0.2em',
|
||||
background: 'rgba(68, 64, 60, 0.65)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '1.1s',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sick effect: floating dizzy spirals around egg */}
|
||||
{/* Sick effect: floating dizzy spirals around egg
|
||||
Uses the same spiral construction as Blobbi dizzy eyes:
|
||||
- True Archimedean spiral path (createEggSpiralPath)
|
||||
- SVG-native rotation animation (animateTransform)
|
||||
- Dark stroke color matching dizzy eye style
|
||||
- Positioned floating around egg, not inside shell
|
||||
*/}
|
||||
{statusEffects?.sick && (
|
||||
<>
|
||||
{/* Spiral 1 - top right */}
|
||||
{/* Spiral 1 - top right, largest */}
|
||||
<svg
|
||||
className="absolute animate-egg-spiral"
|
||||
className="absolute"
|
||||
style={{
|
||||
top: '5%',
|
||||
right: '5%',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
top: '2%',
|
||||
right: '2%',
|
||||
width: '1.1em',
|
||||
height: '1.1em',
|
||||
zIndex: 20,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8)}
|
||||
stroke="rgba(147, 51, 234, 0.7)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8)}
|
||||
stroke="#1f2937"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="360 10 10"
|
||||
to="0 10 10"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/* Spiral 2 - left side */}
|
||||
{/* Spiral 2 - left side, medium */}
|
||||
<svg
|
||||
className="absolute animate-egg-spiral"
|
||||
className="absolute"
|
||||
style={{
|
||||
top: '30%',
|
||||
left: '0%',
|
||||
width: '0.8em',
|
||||
height: '0.8em',
|
||||
top: '35%',
|
||||
left: '-2%',
|
||||
width: '0.9em',
|
||||
height: '0.9em',
|
||||
zIndex: 20,
|
||||
animationDelay: '0.5s',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8)}
|
||||
stroke="rgba(147, 51, 234, 0.6)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8)}
|
||||
stroke="#1f2937"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 10 10"
|
||||
to="360 10 10"
|
||||
dur="2.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/* Spiral 3 - bottom right */}
|
||||
{/* Spiral 3 - bottom right, smallest */}
|
||||
<svg
|
||||
className="absolute animate-egg-spiral"
|
||||
className="absolute"
|
||||
style={{
|
||||
bottom: '20%',
|
||||
right: '0%',
|
||||
width: '0.7em',
|
||||
height: '0.7em',
|
||||
bottom: '18%',
|
||||
right: '-3%',
|
||||
width: '0.75em',
|
||||
height: '0.75em',
|
||||
zIndex: 20,
|
||||
animationDelay: '1s',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8)}
|
||||
stroke="rgba(147, 51, 234, 0.6)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8)}
|
||||
stroke="#1f2937"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="360 10 10"
|
||||
to="0 10 10"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
* emotions.ts should delegate to this function rather than calling
|
||||
* individual generators directly.
|
||||
*
|
||||
* For adult variants, detects body path bounds to enable shape-aware
|
||||
* placement of dirt marks and dust particles.
|
||||
*
|
||||
* @param svgText - The base SVG content (may already have face emotions applied)
|
||||
* @param spec - Which body effects to apply
|
||||
* @returns Modified SVG with body effects applied
|
||||
@@ -34,13 +37,25 @@ export function applyBodyEffects(svgText: string, spec: BodyEffectsSpec): string
|
||||
const idSuffix = spec.idPrefix ?? Math.random().toString(36).slice(2, 8);
|
||||
const variant = spec.variant ?? 'adult';
|
||||
|
||||
// Detect body path for adult shape-aware placement
|
||||
// This enables dirt marks and dust to follow the actual body silhouette
|
||||
const bodyPath = variant === 'adult' ? detectBodyPath(svgText) : null;
|
||||
|
||||
// Dirt marks + dust particles
|
||||
if (spec.dirtyMarks?.enabled) {
|
||||
const dirtMarkup = generateDirtMarks({ ...spec.dirtyMarks, variant });
|
||||
const dirtMarkup = generateDirtMarks({
|
||||
...spec.dirtyMarks,
|
||||
variant,
|
||||
bodyPath: bodyPath ?? undefined,
|
||||
});
|
||||
if (dirtMarkup) overlays.push(dirtMarkup);
|
||||
|
||||
// Add dust particles (includes both back and front layers)
|
||||
const dustMarkup = generateDustParticles({ ...spec.dirtyMarks, variant });
|
||||
const dustMarkup = generateDustParticles({
|
||||
...spec.dirtyMarks,
|
||||
variant,
|
||||
bodyPath: bodyPath ?? undefined,
|
||||
});
|
||||
if (dustMarkup) overlays.push(dustMarkup);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
*
|
||||
* Coordinate systems by variant:
|
||||
* - Baby: 100x100 viewBox, body roughly x: 25-75, y: 15-88
|
||||
* - Adult: 200x200 viewBox, body varies by form but center ~100, width ~70
|
||||
* - Adult: 200x200 viewBox, body varies significantly by form
|
||||
*
|
||||
* For adults, dirt marks use detected body bounds (not hardcoded positions)
|
||||
* to correctly follow each form's actual silhouette.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -19,52 +22,72 @@ import type {
|
||||
// ─── Body Path Detection ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect the body path from the SVG.
|
||||
* Detect the body path from the SVG and extract full bounding box.
|
||||
* Looks for the main body path (body gradient fill or "Body" comment).
|
||||
* Returns both X and Y bounds for shape-aware dirt placement.
|
||||
*/
|
||||
export function detectBodyPath(svgText: string): BodyPathInfo | null {
|
||||
// Strategy 1: path with body gradient fill
|
||||
const bodyGradientMatch = svgText.match(/<path[^>]*d="([^"]+)"[^>]*fill="url\(#[^"]*[Bb]ody[^"]*\)"[^>]*\/>/);
|
||||
if (bodyGradientMatch) {
|
||||
const pathD = bodyGradientMatch[1];
|
||||
const bounds = estimatePathBounds(pathD);
|
||||
return { pathD, ...bounds };
|
||||
return { pathD, ...estimatePathBounds(pathD) };
|
||||
}
|
||||
|
||||
// Strategy 2: path after "Body" comment
|
||||
const commentMatch = svgText.match(/<!--[^>]*[Bb]ody[^>]*-->\s*<path[^>]*d="([^"]+)"/);
|
||||
if (commentMatch) {
|
||||
const pathD = commentMatch[1];
|
||||
const bounds = estimatePathBounds(pathD);
|
||||
return { pathD, ...bounds };
|
||||
return { pathD, ...estimatePathBounds(pathD) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the bounding box of a path from its d attribute.
|
||||
* Estimate the full bounding box of a path from its d attribute.
|
||||
* Extracts both X and Y coordinate ranges for shape-aware placement.
|
||||
*/
|
||||
function estimatePathBounds(pathD: string): { minY: number; maxY: number } {
|
||||
function estimatePathBounds(pathD: string): Omit<BodyPathInfo, 'pathD'> {
|
||||
// Parse path commands to extract coordinate pairs
|
||||
// This handles M, L, Q, C commands by extracting numbers in sequence
|
||||
const numbers = pathD.match(/-?\d+\.?\d*/g)?.map(Number) || [];
|
||||
|
||||
let minY = 100;
|
||||
let maxY = 0;
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (let i = 1; i < numbers.length; i += 2) {
|
||||
const y = numbers[i];
|
||||
if (y !== undefined && y >= 5 && y <= 100) {
|
||||
// Process numbers as x,y pairs
|
||||
for (let i = 0; i < numbers.length - 1; i += 2) {
|
||||
const x = numbers[i];
|
||||
const y = numbers[i + 1];
|
||||
|
||||
if (x !== undefined && !isNaN(x)) {
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
}
|
||||
if (y !== undefined && !isNaN(y)) {
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
if (minY >= maxY) {
|
||||
// Fallback to sensible defaults if parsing failed
|
||||
if (!isFinite(minX) || !isFinite(maxX)) {
|
||||
minX = 20;
|
||||
maxX = 80;
|
||||
}
|
||||
if (!isFinite(minY) || !isFinite(maxY)) {
|
||||
minY = 10;
|
||||
maxY = 90;
|
||||
}
|
||||
|
||||
return { minY, maxY };
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
const centerX = minX + width / 2;
|
||||
|
||||
return { minX, maxX, minY, maxY, centerX, width, height };
|
||||
}
|
||||
|
||||
// ─── Dirt Marks ───────────────────────────────────────────────────────────────
|
||||
@@ -83,17 +106,19 @@ function estimatePathBounds(pathD: string): { minY: number; maxY: number } {
|
||||
* - Side contours in lower half of body
|
||||
*
|
||||
* Baby (100x100 viewBox):
|
||||
* - Uses fixed positions (body shape is consistent)
|
||||
* - Face region: x: 30-70, y: 35-70 (AVOID)
|
||||
* - Safe lower edges: y > 72, prefer x < 35 or x > 65
|
||||
*
|
||||
* Adult (200x200 viewBox):
|
||||
* - Face region: x: 80-120, y: 70-115 (AVOID)
|
||||
* - Safe lower edges: y > 120, prefer x < 85 or x > 115
|
||||
* Adult (variable body shapes):
|
||||
* - Uses detected body bounds for shape-aware placement
|
||||
* - Positions computed relative to actual body silhouette
|
||||
* - Dirt placed at lower 30% of body height, near side edges
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dirt mark positions for baby variant (100x100 viewBox).
|
||||
* Positioned at lower-left and lower-right edges, avoiding face region.
|
||||
* Baby has consistent body shape, so fixed positions work well.
|
||||
*/
|
||||
const BABY_DIRT_POSITIONS = [
|
||||
// Primary marks - lower side edges, well below face
|
||||
@@ -106,18 +131,42 @@ const BABY_DIRT_POSITIONS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Dirt mark positions for adult variant (200x200 viewBox).
|
||||
* Positioned at lower side edges, avoiding face region entirely.
|
||||
* Compute dirt mark positions relative to detected body bounds.
|
||||
* Places marks at lower-left and lower-right edges, avoiding face region.
|
||||
*
|
||||
* @param bodyPath - Detected body path with bounds
|
||||
* @param count - Number of marks to generate
|
||||
* @returns Array of position objects relative to actual body silhouette
|
||||
*/
|
||||
const ADULT_DIRT_POSITIONS = [
|
||||
// Primary marks - lower side edges
|
||||
{ x: 78, y: 125, angle: 30, length: 4 }, // lower-left side
|
||||
{ x: 122, y: 122, angle: -25, length: 4 }, // lower-right side
|
||||
{ x: 80, y: 138, angle: 20, length: 3.5 }, // very low left
|
||||
// Additional marks for higher counts
|
||||
{ x: 120, y: 135, angle: -20, length: 3.5 }, // very low right
|
||||
{ x: 100, y: 145, angle: 5, length: 3 }, // bottom center (safe - well below face)
|
||||
];
|
||||
function computeAdultDirtPositions(
|
||||
bodyPath: BodyPathInfo,
|
||||
count: number
|
||||
): Array<{ x: number; y: number; angle: number; length: number }> {
|
||||
const { minX, maxX, minY, centerX, width, height } = bodyPath;
|
||||
|
||||
// Safe zone: lower 35% of body height (well below face)
|
||||
const safeTopY = minY + height * 0.65;
|
||||
|
||||
// Edge margins: 15% inward from body edges
|
||||
const leftEdgeX = minX + width * 0.15;
|
||||
const rightEdgeX = maxX - width * 0.15;
|
||||
|
||||
// Mark length scales with body size
|
||||
const markLength = Math.max(3, width * 0.05);
|
||||
|
||||
// All possible positions - ordered by priority
|
||||
const allPositions = [
|
||||
// Primary marks - lower side edges
|
||||
{ x: leftEdgeX, y: safeTopY + height * 0.08, angle: 25, length: markLength },
|
||||
{ x: rightEdgeX, y: safeTopY + height * 0.05, angle: -20, length: markLength },
|
||||
{ x: leftEdgeX + width * 0.05, y: safeTopY + height * 0.18, angle: 15, length: markLength * 0.85 },
|
||||
// Additional marks for higher counts
|
||||
{ x: rightEdgeX - width * 0.05, y: safeTopY + height * 0.15, angle: -15, length: markLength * 0.85 },
|
||||
{ x: centerX, y: safeTopY + height * 0.22, angle: 5, length: markLength * 0.75 },
|
||||
];
|
||||
|
||||
return allPositions.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dirt marks/scratches on the body.
|
||||
@@ -126,8 +175,9 @@ const ADULT_DIRT_POSITIONS = [
|
||||
* - AVOID face region (eyes, mouth, eyebrows, tears, drool, blush, sparkles)
|
||||
* - PREFER lower-left and lower-right edges of body silhouette
|
||||
* - Bottom edge placement is safe (well below face elements)
|
||||
* - Adult uses detected body bounds; baby uses fixed positions
|
||||
*
|
||||
* @param config - Dirt marks configuration including variant
|
||||
* @param config - Dirt marks configuration including variant and bodyPath
|
||||
* @returns SVG markup for dirt marks
|
||||
*/
|
||||
export function generateDirtMarks(config: DirtMarksConfig): string {
|
||||
@@ -137,9 +187,26 @@ export function generateDirtMarks(config: DirtMarksConfig): string {
|
||||
const variant = config.variant ?? 'adult';
|
||||
const marks: string[] = [];
|
||||
|
||||
// Select positions based on variant
|
||||
const basePositions = variant === 'baby' ? BABY_DIRT_POSITIONS : ADULT_DIRT_POSITIONS;
|
||||
const positions = basePositions.slice(0, count);
|
||||
// Compute positions based on variant
|
||||
let positions: Array<{ x: number; y: number; angle: number; length: number }>;
|
||||
|
||||
if (variant === 'adult' && config.bodyPath) {
|
||||
// Adult with detected body: use shape-aware placement
|
||||
positions = computeAdultDirtPositions(config.bodyPath, count);
|
||||
} else if (variant === 'baby') {
|
||||
// Baby: use fixed positions (consistent body shape)
|
||||
positions = BABY_DIRT_POSITIONS.slice(0, count);
|
||||
} else {
|
||||
// Adult fallback without body detection: use conservative defaults
|
||||
// These are positioned relative to typical adult body center
|
||||
positions = [
|
||||
{ x: 78, y: 130, angle: 25, length: 4 },
|
||||
{ x: 122, y: 128, angle: -20, length: 4 },
|
||||
{ x: 80, y: 145, angle: 15, length: 3.5 },
|
||||
{ x: 120, y: 142, angle: -15, length: 3.5 },
|
||||
{ x: 100, y: 152, angle: 5, length: 3 },
|
||||
].slice(0, count);
|
||||
}
|
||||
|
||||
// Scale factors for stroke width based on viewBox
|
||||
const strokeWidth = variant === 'baby' ? 1.3 : 2;
|
||||
@@ -180,36 +247,63 @@ const BABY_DUST_POSITIONS = {
|
||||
{ x: 65, y: 89, r: 1.3, delay: 0.6 },
|
||||
],
|
||||
// Front layer - at lower side edges, NOT in face region
|
||||
// Stronger visibility than back layer
|
||||
front: [
|
||||
{ x: 28, y: 78, r: 1.0, delay: 0.1 }, // lower-left edge
|
||||
{ x: 70, y: 76, r: 0.9, delay: 0.4 }, // lower-right edge
|
||||
{ x: 32, y: 84, r: 0.8, delay: 0.7 }, // very low left
|
||||
{ x: 28, y: 78, r: 1.2, delay: 0.1 }, // lower-left edge
|
||||
{ x: 70, y: 76, r: 1.1, delay: 0.4 }, // lower-right edge
|
||||
{ x: 32, y: 84, r: 1.0, delay: 0.7 }, // very low left
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dust particle positions for adult variant (200x200 viewBox).
|
||||
* All particles positioned at lower edges, avoiding face region.
|
||||
* Compute dust particle positions relative to detected body bounds.
|
||||
* Places particles at lower edges, avoiding face region.
|
||||
*
|
||||
* @param bodyPath - Detected body path with bounds
|
||||
* @returns Object with back and front layer particle positions
|
||||
*/
|
||||
const ADULT_DUST_POSITIONS = {
|
||||
// Back layer - below the body
|
||||
back: [
|
||||
{ x: 80, y: 175, r: 2.5, delay: 0 },
|
||||
{ x: 100, y: 180, r: 2.2, delay: 0.3 },
|
||||
{ x: 120, y: 173, r: 2.3, delay: 0.6 },
|
||||
],
|
||||
// Front layer - at lower side edges, NOT in face region
|
||||
front: [
|
||||
{ x: 75, y: 130, r: 1.8, delay: 0.1 }, // lower-left side
|
||||
{ x: 125, y: 128, r: 1.6, delay: 0.4 }, // lower-right side
|
||||
{ x: 78, y: 142, r: 1.4, delay: 0.7 }, // very low left
|
||||
],
|
||||
};
|
||||
function computeAdultDustPositions(bodyPath: BodyPathInfo): {
|
||||
back: Array<{ x: number; y: number; r: number; delay: number }>;
|
||||
front: Array<{ x: number; y: number; r: number; delay: number }>;
|
||||
} {
|
||||
const { minX, maxX, maxY, centerX, width, height } = bodyPath;
|
||||
|
||||
// Back layer: below the body bottom
|
||||
const backY = maxY + height * 0.05;
|
||||
|
||||
// Front layer: lower 30% of body, at side edges
|
||||
const frontTopY = maxY - height * 0.3;
|
||||
|
||||
// Edge positions
|
||||
const leftEdgeX = minX + width * 0.15;
|
||||
const rightEdgeX = maxX - width * 0.15;
|
||||
|
||||
// Particle radius scales with body size
|
||||
const baseRadius = Math.max(1.5, width * 0.025);
|
||||
|
||||
return {
|
||||
back: [
|
||||
{ x: leftEdgeX, y: backY, r: baseRadius, delay: 0 },
|
||||
{ x: centerX, y: backY + 3, r: baseRadius * 0.85, delay: 0.3 },
|
||||
{ x: rightEdgeX, y: backY - 2, r: baseRadius * 0.9, delay: 0.6 },
|
||||
],
|
||||
front: [
|
||||
{ x: leftEdgeX - width * 0.05, y: frontTopY + height * 0.08, r: baseRadius * 0.9, delay: 0.1 },
|
||||
{ x: rightEdgeX + width * 0.05, y: frontTopY + height * 0.05, r: baseRadius * 0.85, delay: 0.4 },
|
||||
{ x: leftEdgeX, y: frontTopY + height * 0.18, r: baseRadius * 0.75, delay: 0.7 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate animated dust particles around the Blobbi.
|
||||
* Creates both back-layer (below body) and front-layer (in front of body) particles
|
||||
* for a stronger "dirty" visual effect.
|
||||
*
|
||||
* Distribution:
|
||||
* - Back layer: underneath/behind the body (lower z-index)
|
||||
* - Front layer: near lower body edges, in front (higher z-index)
|
||||
* - Front dust is more visible (larger, higher opacity)
|
||||
*
|
||||
* @param config - Dirt marks configuration (reused for dust)
|
||||
* @returns SVG markup for dust particles
|
||||
@@ -220,28 +314,54 @@ export function generateDustParticles(config: DirtMarksConfig): string {
|
||||
const variant = config.variant ?? 'adult';
|
||||
const particles: string[] = [];
|
||||
|
||||
const positions = variant === 'baby' ? BABY_DUST_POSITIONS : ADULT_DUST_POSITIONS;
|
||||
// Compute positions based on variant
|
||||
let positions: {
|
||||
back: Array<{ x: number; y: number; r: number; delay: number }>;
|
||||
front: Array<{ x: number; y: number; r: number; delay: number }>;
|
||||
};
|
||||
|
||||
if (variant === 'adult' && config.bodyPath) {
|
||||
// Adult with detected body: use shape-aware placement
|
||||
positions = computeAdultDustPositions(config.bodyPath);
|
||||
} else if (variant === 'baby') {
|
||||
// Baby: use fixed positions
|
||||
positions = BABY_DUST_POSITIONS;
|
||||
} else {
|
||||
// Adult fallback: conservative defaults
|
||||
positions = {
|
||||
back: [
|
||||
{ x: 80, y: 175, r: 2.5, delay: 0 },
|
||||
{ x: 100, y: 180, r: 2.2, delay: 0.3 },
|
||||
{ x: 120, y: 173, r: 2.3, delay: 0.6 },
|
||||
],
|
||||
front: [
|
||||
{ x: 75, y: 135, r: 2.0, delay: 0.1 },
|
||||
{ x: 125, y: 132, r: 1.8, delay: 0.4 },
|
||||
{ x: 78, y: 148, r: 1.6, delay: 0.7 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Generate back layer particles
|
||||
// Generate back layer particles (underneath body)
|
||||
positions.back.forEach((pos, i) => {
|
||||
particles.push(`<circle
|
||||
class="blobbi-dust-particle blobbi-dust-back-${i}"
|
||||
cx="${pos.x}"
|
||||
cy="${pos.y}"
|
||||
r="${pos.r}"
|
||||
cx="${pos.x.toFixed(1)}"
|
||||
cy="${pos.y.toFixed(1)}"
|
||||
r="${pos.r.toFixed(1)}"
|
||||
fill="#57534e"
|
||||
opacity="0.6"
|
||||
opacity="0.55"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.6;0.3;0.6"
|
||||
values="0.55;0.3;0.55"
|
||||
dur="2s"
|
||||
begin="${pos.delay}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values="${pos.y};${pos.y - 2};${pos.y}"
|
||||
values="${pos.y.toFixed(1)};${(pos.y - 2).toFixed(1)};${pos.y.toFixed(1)}"
|
||||
dur="2s"
|
||||
begin="${pos.delay}s"
|
||||
repeatCount="indefinite"
|
||||
@@ -249,26 +369,26 @@ export function generateDustParticles(config: DirtMarksConfig): string {
|
||||
</circle>`);
|
||||
});
|
||||
|
||||
// Generate front layer particles with slightly stronger opacity
|
||||
// Generate front layer particles (in front of body, more visible)
|
||||
positions.front.forEach((pos, i) => {
|
||||
particles.push(`<circle
|
||||
class="blobbi-dust-particle blobbi-dust-front-${i}"
|
||||
cx="${pos.x}"
|
||||
cy="${pos.y}"
|
||||
r="${pos.r}"
|
||||
fill="#44403c"
|
||||
opacity="0.7"
|
||||
cx="${pos.x.toFixed(1)}"
|
||||
cy="${pos.y.toFixed(1)}"
|
||||
r="${pos.r.toFixed(1)}"
|
||||
fill="#3f3f46"
|
||||
opacity="0.75"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.7;0.4;0.7"
|
||||
values="0.75;0.45;0.75"
|
||||
dur="2.5s"
|
||||
begin="${pos.delay}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="cy"
|
||||
values="${pos.y};${pos.y - 3};${pos.y}"
|
||||
values="${pos.y.toFixed(1)};${(pos.y - 3).toFixed(1)};${pos.y.toFixed(1)}"
|
||||
dur="2.5s"
|
||||
begin="${pos.delay}s"
|
||||
repeatCount="indefinite"
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface DirtMarksConfig {
|
||||
count?: number;
|
||||
/** Blobbi variant for coordinate scaling (default: 'adult') */
|
||||
variant?: BlobbiVariant;
|
||||
/** Detected body path info for shape-aware placement (adult only) */
|
||||
bodyPath?: BodyPathInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +82,18 @@ export interface BodyEffectsSpec {
|
||||
|
||||
/**
|
||||
* Internal: detected body path info for body-level effects.
|
||||
* Used to place dirt marks relative to the actual body silhouette.
|
||||
*/
|
||||
export interface BodyPathInfo {
|
||||
pathD: string;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
/** Center X coordinate of the body */
|
||||
centerX: number;
|
||||
/** Width of the body */
|
||||
width: number;
|
||||
/** Height of the body */
|
||||
height: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user