diff --git a/src/app/components/seasonal/themes/Halloween.css.ts b/src/app/components/seasonal/themes/Halloween.css.ts index 70ce577ce..110e80265 100644 --- a/src/app/components/seasonal/themes/Halloween.css.ts +++ b/src/app/components/seasonal/themes/Halloween.css.ts @@ -82,3 +82,65 @@ export const animGhostWobble = keyframes({ '50%': { transform: 'rotate(4deg)' }, '100%': { transform: 'rotate(-4deg)' }, }); + +/** [#239] Will-o'-the-wisp: a slow wandering loop (paired with a pulse). */ +export const animWispWander = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0)' }, + '25%': { transform: 'translate3d(40px, -30px, 0)' }, + '50%': { transform: 'translate3d(-10px, -60px, 0)' }, + '75%': { transform: 'translate3d(-45px, -20px, 0)' }, + '100%': { transform: 'translate3d(0, 0, 0)' }, +}); + +/** Soft breathing glow of a wisp. */ +export const animWispPulse = keyframes({ + '0%': { opacity: '0.35', transform: 'scale(0.85)' }, + '50%': { opacity: '0.9', transform: 'scale(1.1)' }, + '100%': { opacity: '0.35', transform: 'scale(0.85)' }, +}); + +/** A witch on a broom crosses high up, right to left, in ~11 s of 60 s. */ +export const animWitch = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0) rotate(-4deg)', opacity: '0' }, + '1%': { opacity: '1' }, + '9%': { transform: 'translate3d(-60vw, -3vh, 0) rotate(3deg)' }, + '17.5%': { opacity: '1' }, + '18%': { transform: 'translate3d(-118vw, 2vh, 0) rotate(-3deg)', opacity: '0' }, + '100%': { transform: 'translate3d(-118vw, 2vh, 0) rotate(-3deg)', opacity: '0' }, +}); + +/** + * The big spider: bobs for most of the cycle, then climbs up its thread, + * pauses, and drops back with a little bounce (24 s cycle). + */ +export const animSpiderClimb = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0)' }, + '20%': { transform: 'translate3d(0, 14px, 0)' }, + '40%': { transform: 'translate3d(0, 0, 0)' }, + '55%': { transform: 'translate3d(0, -22vh, 0)' }, + '68%': { transform: 'translate3d(0, -22vh, 0)' }, + '71%': { transform: 'translate3d(0, 24px, 0)' }, + '74%': { transform: 'translate3d(0, -8px, 0)' }, + '77%': { transform: 'translate3d(0, 6px, 0)' }, + '80%': { transform: 'translate3d(0, 0, 0)' }, + '100%': { transform: 'translate3d(0, 0, 0)' }, +}); + +/** Jack-o'-lantern candle flicker (opacity of the glow behind the face). */ +export const animCandle = keyframes({ + '0%': { opacity: '0.75' }, + '20%': { opacity: '1' }, + '35%': { opacity: '0.6' }, + '55%': { opacity: '0.95' }, + '70%': { opacity: '0.7' }, + '100%': { opacity: '0.75' }, +}); + +/** The swarm crosses faster than the regular bats: ~7 s of a 60 s cycle. */ +export const animSwarmFlight = keyframes({ + '0%': { transform: 'translate3d(-10vw, 0, 0)', opacity: '0' }, + '0.5%': { opacity: '1' }, + '11.5%': { opacity: '1' }, + '12%': { transform: 'translate3d(110vw, -8vh, 0)', opacity: '0' }, + '100%': { transform: 'translate3d(110vw, -8vh, 0)', opacity: '0' }, +}); diff --git a/src/app/components/seasonal/themes/Halloween.tsx b/src/app/components/seasonal/themes/Halloween.tsx index dc3298e81..3b29db4b6 100644 --- a/src/app/components/seasonal/themes/Halloween.tsx +++ b/src/app/components/seasonal/themes/Halloween.tsx @@ -4,10 +4,16 @@ import { animBatBob, animBatFlap, animBatFlight, + animCandle, animGhost, animGhostWobble, animSpiderBob, + animSpiderClimb, animSpiderSway, + animSwarmFlight, + animWispPulse, + animWispWander, + animWitch, halloweenRoot, } from './Halloween.css'; @@ -134,30 +140,34 @@ function Spider({ size }: { size: number }) { ); } -/** Spider hanging from the top edge; thread and body bob together. */ +/** Spider hanging from the top edge; thread and body move together. */ function HangingSpider({ left, length, animate, + size = 30, + climb = false, }: { left: string; length: string; animate: boolean; + size?: number; + climb?: boolean; }) { + let motion: string | undefined; + if (animate) { + motion = climb + ? `${animSpiderClimb} 24s ease-in-out 6s infinite` + : `${animSpiderBob} 7s ease-in-out infinite`; + } return (
); } -const BAT_PERIOD = 30; // s between flights +/** Small jack-o'-lantern hanging on a short thread, candle flickering. */ +function HangingLantern({ right, length, size }: { right: number; length: number; size: number }) { + return ( + + ); +} + +// Witch on a broom, flying left (viewBox 0 0 120 60). +const WITCH_PATH = [ + 'M4 42 L80 37 L80 40 L4 45 Z', // broom handle + 'M80 34 C88 32 98 28 108 24 C104 32 106 38 116 46 C104 44 92 42 80 42 Z', // bristles + 'M32 20 C36 17 50 16 58 19 L55 21 C50 19 40 20 35 22 Z', // hat brim + 'M40 19 L64 2 L52 19 Z', // hat cone, leaning back + 'M39 25 A5 5 0 1 0 49 25 A5 5 0 1 0 39 25 Z', // head + 'M39 24 L34 26 L39 27 Z', // nose + 'M40 29 C44 28 52 28 56 30 C64 30 72 28 80 24 C76 30 70 34 62 38 L44 38 C40 36 38 32 40 29 Z', // cloak + 'M42 31 L32 37 L34 39 L44 34 Z', // arm on the broom + 'M48 38 L44 50 L38 52 L38 54 L47 53 L52 39 Z', // leg and boot +].join(' '); + +function Witch({ width }: { width: number }) { + return ( + + ); +} + +const WISPS = [ + { left: 12, top: 62, size: 30, hue: 'oklch(0.82 0.19 145)', wander: 26, pulse: 3.2, delay: -3 }, + { left: 34, top: 80, size: 24, hue: 'oklch(0.8 0.16 60)', wander: 31, pulse: 2.6, delay: -11 }, + { left: 55, top: 48, size: 28, hue: 'oklch(0.82 0.19 145)', wander: 28, pulse: 3.8, delay: -7 }, + { left: 72, top: 72, size: 34, hue: 'oklch(0.8 0.16 60)', wander: 34, pulse: 2.9, delay: -19 }, + { left: 88, top: 55, size: 24, hue: 'oklch(0.82 0.19 145)', wander: 24, pulse: 3.4, delay: -5 }, + { left: 46, top: 30, size: 22, hue: 'oklch(0.8 0.16 60)', wander: 29, pulse: 2.4, delay: -15 }, +]; + +const BAT_PERIOD = 20; // s between flights const BAT_FIRST = 5; // s after load const BATS = [ { top: 9, width: 64, offset: 0, bob: 1.3, flap: 0.3 }, @@ -185,6 +273,20 @@ const BATS = [ { top: 20, width: 54, offset: 1.4, bob: 1.2, flap: 0.28 }, ]; +// Every 60 s a bigger, faster swarm of small bats, offset from the regular group. +const SWARM_PERIOD = 60; +const SWARM_FIRST = 38; +const SWARM = Array.from({ length: 8 }, (_, i) => ({ + top: 4 + ((i * 7) % 22), + width: 22 + (i % 3) * 8, + offset: (i * 0.23) % 1.4, + bob: 0.9 + (i % 3) * 0.25, + flap: 0.18 + (i % 3) * 0.03, +})); + +const WITCH_PERIOD = 60; +const WITCH_FIRST = 22; + const GHOST_PERIOD = 50; const GHOST_FIRST = 16; @@ -269,7 +371,41 @@ export function HalloweenOverlay({ reduced, preview }: SeasonalOverlayProps) { left="calc(100% - 64px)" length={narrow ? '22vh' : '50vh'} animate={!reduced} + climb /> + {/* A second, smaller spider on the left web. */} +