Files
cinny/src/app/components/seasonal/themes/Halloween.tsx
T

268 lines
8.8 KiB
TypeScript
Raw Normal View History

import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animMoonPulse,
animFogDrift,
animBatGlide,
animWingFlap,
animEmberFloat,
animEmberTwinkle,
} from './Halloween.css';
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
const cobwebUri = (() => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
// radial threads
`<line x1='0' y1='0' x2='180' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='90'/>` +
`<line x1='0' y1='0' x2='90' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='40'/>` +
`<line x1='0' y1='0' x2='40' y2='180'/>` +
// concentric catch-threads (gentle sag via quadratic curves)
`<path d='M40 0 Q22 22 0 40'/>` +
`<path d='M85 0 Q48 48 0 85'/>` +
`<path d='M130 0 Q74 74 0 130'/>` +
`<path d='M180 0 Q104 104 0 180'/>` +
`<path d='M180 60 Q120 120 60 180'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
// glide while an inner element flaps independently — we re-use one body shape.
function BatSilhouette() {
return (
<svg
width="46"
height="22"
viewBox="0 0 46 22"
aria-hidden="true"
style={{ display: 'block', overflow: 'visible' }}
>
<path
fill="oklch(0.12 0.04 300 / 0.85)"
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
/>
</svg>
);
}
export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic per-mount generation — never per-frame React state.
const embers = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => {
const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
return {
left: (i * 6151 + 113) % 100,
bottom: (i * 3137 + 47) % 28, // start near floor
size: 3 + (i % 4),
duration: 11 + (i % 6) * 2.2,
delay: (i * 0.83) % 11,
twinkle: 2.4 + (i % 5) * 0.6,
color: green ? TOXIC_GREEN : EMBER_ORANGE,
};
}),
[],
);
const bats = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
top: 8 + i * 13,
duration: 22 + i * 7,
delay: i * 6.5,
flap: 0.5 + i * 0.12,
scale: 0.7 + i * 0.18,
})),
[],
);
const fogBands = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
bottom: -6 + i * 9,
duration: 26 + i * 8,
delay: i * 5,
height: 130 + i * 30,
})),
[],
);
return (
<>
{/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundColor: 'transparent',
backgroundImage: [
// sickly moon glow, upper-right
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
// cold counter-glow lower-left for depth
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
// overall indigo→black wash, darker toward edges (vignette)
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
].join(', '),
opacity: 0.5,
}}
/>
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
right: '12%',
width: '160px',
height: '160px',
borderRadius: '50%',
willChange: 'transform, opacity',
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
filter: 'blur(2px)',
backdropFilter: 'saturate(1.15)',
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
}}
/>
{/* ── Low drifting fog bands ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
overflow: 'hidden',
}}
>
{fogBands.map((f, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: '-15%',
right: '-15%',
bottom: `${f.bottom}%`,
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
? 'none'
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
}}
/>
))}
</div>
{/* ── Will-o'-wisps / floating embers ── */}
{embers.map((e, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
bottom: `${e.bottom}%`,
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(0.9)' : undefined,
opacity: reduced ? 0.4 : undefined,
animation: reduced
? 'none'
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
width: `${e.size}px`,
height: `${e.size}px`,
borderRadius: '50%',
backgroundColor: e.color,
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
animation: reduced
? 'none'
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
}}
/>
</div>
))}
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
{!reduced &&
bats.map((b, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: `${b.top}%`,
left: 0,
willChange: 'transform, opacity',
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
transform: `scale(${b.scale})`,
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
}}
>
<BatSilhouette />
</div>
</div>
))}
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
opacity: 0.7,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
right: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
transform: 'scaleX(-1)',
opacity: 0.7,
}}
/>
</>
);
}