@@ -33,6 +33,99 @@ function isVideo(url: string) {
3333 return / \. ( m p 4 | w e b m | o g g ) $ / i. test ( url ) ;
3434}
3535
36+ // ── Spiral + SplashOverlay ──────────────────────────────────────────────────
37+ const MAGIC_COLORS = [ "#FFD700" , "#FFFFFF" , "#FF88FF" , "#44FFFF" , "#FFAA44" , "#FF44AA" , "#BBFFAA" , "#FF6644" ] ;
38+ const SPLASH_DURATION = 3500 ;
39+
40+ const SPIRAL = ( ( ) => {
41+ const rotations = 2 , steps = 48 , rxMax = 960 , ryMax = 560 ;
42+ const xs : number [ ] = [ ] , ys : number [ ] = [ ] , scales : number [ ] = [ ] , times : number [ ] = [ ] ;
43+ for ( let i = 0 ; i <= steps ; i ++ ) {
44+ const t = i / steps , theta = t * rotations * 2 * Math . PI ;
45+ const rx = rxMax * ( 1 - t ) , ry = ryMax * ( 1 - t ) ;
46+ xs . push ( Math . round ( rx * Math . sin ( theta ) ) ) ;
47+ ys . push ( Math . round ( - ry * Math . cos ( theta ) ) ) ;
48+ scales . push ( Math . round ( ( 0.05 + 0.95 * t ) * 100 ) / 100 ) ;
49+ times . push ( Math . round ( t * 10000 ) / 10000 ) ;
50+ }
51+ return { xs, ys, scales, times } ;
52+ } ) ( ) ;
53+
54+ type SparkParticle = { x : number ; y : number ; vx : number ; vy : number ; alpha : number ; size : number ; color : string ; life : number ; decay : number ; } ;
55+
56+ function interpolateSpiral ( t : number ) {
57+ const times = SPIRAL . times ;
58+ let i = times . length - 2 ;
59+ for ( let j = 0 ; j < times . length - 1 ; j ++ ) { if ( t <= times [ j + 1 ] ) { i = j ; break ; } }
60+ const seg = times [ i + 1 ] === times [ i ] ? 0 : ( t - times [ i ] ) / ( times [ i + 1 ] - times [ i ] ) ;
61+ return { x : SPIRAL . xs [ i ] + ( SPIRAL . xs [ i + 1 ] - SPIRAL . xs [ i ] ) * seg , y : SPIRAL . ys [ i ] + ( SPIRAL . ys [ i + 1 ] - SPIRAL . ys [ i ] ) * seg } ;
62+ }
63+
64+ function SplashOverlay ( { url, onDismiss } : { url : string ; onDismiss : ( ) => void } ) {
65+ const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
66+ const particlesRef = useRef < SparkParticle [ ] > ( [ ] ) ;
67+ const rafRef = useRef < number | undefined > ( undefined ) ;
68+ const startRef = useRef < number > ( 0 ) ;
69+
70+ useEffect ( ( ) => {
71+ const canvas = canvasRef . current ;
72+ if ( ! canvas ) return ;
73+ const ctx = canvas . getContext ( "2d" ) ;
74+ if ( ! ctx ) return ;
75+ const resize = ( ) => { canvas . width = window . innerWidth ; canvas . height = window . innerHeight ; } ;
76+ resize ( ) ;
77+ window . addEventListener ( "resize" , resize ) ;
78+ startRef . current = performance . now ( ) ;
79+ particlesRef . current = [ ] ;
80+
81+ const loop = ( ) => {
82+ rafRef . current = requestAnimationFrame ( loop ) ;
83+ ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
84+ const elapsed = performance . now ( ) - startRef . current ;
85+ const t = Math . min ( elapsed / SPLASH_DURATION , 1 ) ;
86+ const pos = interpolateSpiral ( t ) ;
87+ const cx = canvas . width / 2 + pos . x , cy = canvas . height / 2 + pos . y ;
88+ if ( t < 0.98 ) {
89+ const sparkCount = Math . round ( 6 + t * 20 ) ;
90+ const currentScale = 0.05 + 0.95 * t ;
91+ const maxRadius = Math . min ( canvas . width * 0.39 , canvas . height * 0.39 ) ;
92+ const raccoonRadius = maxRadius * currentScale ;
93+ for ( let k = 0 ; k < sparkCount ; k ++ ) {
94+ const spawnAngle = Math . random ( ) * Math . PI * 2 , speed = 0.8 + Math . random ( ) * 3.5 ;
95+ const spawnX = cx + Math . cos ( spawnAngle ) * raccoonRadius , spawnY = cy + Math . sin ( spawnAngle ) * raccoonRadius ;
96+ particlesRef . current . push ( { x : spawnX , y : spawnY , vx : Math . cos ( spawnAngle ) * speed + ( Math . random ( ) - 0.5 ) * 1.5 , vy : Math . sin ( spawnAngle ) * speed - 0.8 + ( Math . random ( ) - 0.5 ) * 1.5 , alpha : 1 , size : 2 + Math . random ( ) * 5 , color : MAGIC_COLORS [ Math . floor ( Math . random ( ) * MAGIC_COLORS . length ) ] , life : 0 , decay : 0.016 + Math . random ( ) * 0.024 } ) ;
97+ }
98+ }
99+ const alive : SparkParticle [ ] = [ ] ;
100+ for ( const p of particlesRef . current ) {
101+ p . life += p . decay ; p . x += p . vx ; p . y += p . vy ; p . vy += 0.1 ; p . vx *= 0.97 ; p . size *= 0.97 ; p . alpha = Math . max ( 0 , 1 - p . life ) ;
102+ if ( p . life < 1 && p . size > 0.3 ) {
103+ alive . push ( p ) ;
104+ ctx . save ( ) ; ctx . globalAlpha = p . alpha * 0.85 ; ctx . shadowBlur = 14 ; ctx . shadowColor = p . color ; ctx . fillStyle = p . color ;
105+ ctx . beginPath ( ) ; ctx . arc ( p . x , p . y , p . size , 0 , Math . PI * 2 ) ; ctx . fill ( ) ;
106+ ctx . globalAlpha = p . alpha * 0.55 ; ctx . shadowBlur = 0 ; ctx . fillStyle = "#FFFFFF" ;
107+ ctx . beginPath ( ) ; ctx . arc ( p . x , p . y , p . size * 0.38 , 0 , Math . PI * 2 ) ; ctx . fill ( ) ; ctx . restore ( ) ;
108+ }
109+ }
110+ particlesRef . current = alive ;
111+ } ;
112+ loop ( ) ;
113+ return ( ) => { if ( rafRef . current ) cancelAnimationFrame ( rafRef . current ) ; window . removeEventListener ( "resize" , resize ) ; } ;
114+ } , [ ] ) ;
115+
116+ return (
117+ < motion . div className = "fixed inset-0 z-[200] flex items-center justify-center cursor-pointer select-none" onClick = { onDismiss } initial = { { opacity : 0 } } animate = { { opacity : 1 } } exit = { { opacity : 0 , transition : { duration : 0.25 } } } >
118+ < canvas ref = { canvasRef } className = "absolute inset-0 pointer-events-none" />
119+ < motion . img src = { resolveUrl ( url ) } alt = "" draggable = { false }
120+ initial = { { x : SPIRAL . xs [ 0 ] , y : SPIRAL . ys [ 0 ] , scale : 0.05 , rotate : 0 } }
121+ animate = { { x : SPIRAL . xs , y : SPIRAL . ys , scale : SPIRAL . scales , rotate : 0 , transition : { duration : 3.5 , ease : "linear" , times : SPIRAL . times } } }
122+ exit = { { scale : 0 , opacity : 0 , transition : { duration : 0.28 , ease : "easeIn" } } }
123+ style = { { maxWidth : "78vw" , maxHeight : "78vh" , objectFit : "contain" , pointerEvents : "none" , position : "relative" } }
124+ />
125+ </ motion . div >
126+ ) ;
127+ }
128+
36129const FW_COLORS = [
37130 "#FFD700" , "#FF4444" , "#44DDFF" , "#FF44FF" , "#44FF88" ,
38131 "#FF8844" , "#FFFFFF" , "#FFAA00" , "#AA44FF" , "#44FFFF" ,
@@ -170,6 +263,7 @@ export function QuestionModal({
170263 const [ awarded , setAwarded ] = useState < number | null > ( null ) ;
171264 const [ countdown , setCountdown ] = useState ( TIMER_SECONDS ) ;
172265 const [ showFireworks , setShowFireworks ] = useState ( false ) ;
266+ const [ splashDismissed , setSplashDismissed ] = useState ( false ) ;
173267 const intervalRef = useRef < ReturnType < typeof setInterval > | null > ( null ) ;
174268 const isCelebration = ( themeName === "Зацени Look" && points === 500 ) || ( themeName === "Боссы" && points === 200 ) ;
175269
@@ -203,6 +297,7 @@ export function QuestionModal({
203297 setStage ( "question" ) ;
204298 setIsEditing ( false ) ;
205299 setShowFireworks ( false ) ;
300+ setSplashDismissed ( false ) ;
206301 startTimer ( ) ;
207302 } else {
208303 stopTimer ( ) ;
@@ -254,6 +349,11 @@ export function QuestionModal({
254349 return (
255350 < >
256351 < Fireworks active = { showFireworks } />
352+ < AnimatePresence >
353+ { isOpen && question . splashUrl && ! splashDismissed && (
354+ < SplashOverlay url = { question . splashUrl } onDismiss = { ( ) => setSplashDismissed ( true ) } />
355+ ) }
356+ </ AnimatePresence >
257357 < AnimatePresence >
258358 { isOpen && (
259359 < >
@@ -276,13 +376,23 @@ export function QuestionModal({
276376 { /* Header */ }
277377 < div className = "flex-shrink-0 flex items-center justify-between px-5 lg:px-8 py-3 lg:py-4 border-b border-border/60 bg-muted/20" >
278378 < div className = "flex items-center gap-4 lg:gap-6" >
279- < div >
280- < div className = "text-xs font-bold text-accent uppercase tracking-[0.2em] mb-0.5" >
281- { themeName }
282- </ div >
283- < div className = "font-display text-3xl lg:text-5xl text-primary glow-text leading-none" >
284- { points }
379+ < div className = "flex items-center gap-2 lg:gap-3" >
380+ < div >
381+ < div className = "text-xs font-bold text-accent uppercase tracking-[0.2em] mb-0.5" >
382+ { themeName }
383+ </ div >
384+ < div className = "font-display text-3xl lg:text-5xl text-primary glow-text leading-none" >
385+ { points }
386+ </ div >
285387 </ div >
388+ { question . splashUrl && (
389+ < img
390+ src = { resolveUrl ( question . splashUrl ) }
391+ alt = ""
392+ className = "object-contain select-none pointer-events-none"
393+ style = { { width : "46px" , height : "46px" , filter : "drop-shadow(0 0 5px hsla(45,100%,60%,0.55))" } }
394+ />
395+ ) }
286396 </ div >
287397 < div className = "flex items-center gap-2 ml-2" >
288398 < div className = { `px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border transition-all ${ stage === "question" ? "bg-primary/20 text-primary border-primary/50" : "bg-muted/30 text-muted-foreground border-border" } ` } >
@@ -567,7 +677,7 @@ export function QuestionModal({
567677
568678 { /* Center — timer (only on question stage, not for celebration) */ }
569679 < div className = "flex justify-center" >
570- { stage === "question" && ! isCelebration && (
680+ { stage === "question" && ! isCelebration && ! question . splashUrl && (
571681 < motion . div
572682 initial = { { opacity : 0 , scale : 0.7 } }
573683 animate = { { opacity : 1 , scale : 1 } }
0 commit comments