11import { PluginType } from 'constants/pluginType' ;
22
3+ type StarShape = 'circle' | 'x' | 'plus' ;
4+
35interface Star {
46 x : number ;
57 y : number ;
68 radius : number ;
79 maxAlpha : number ;
810 hue : number ;
11+ sat : number ;
12+ shape : StarShape ;
913 fadeIn : number ;
1014 hold : number ;
1115 fadeOut : number ;
1216 totalLife : number ;
1317 age : number ;
1418}
1519
20+ interface Meteor {
21+ x : number ;
22+ y : number ;
23+ vx : number ;
24+ vy : number ;
25+ length : number ;
26+ life : number ;
27+ maxLife : number ;
28+ hue : number ;
29+ }
30+
31+ const CONFIG = {
32+ maxStarsNormal : 100 ,
33+ maxStarsReducedMotion : 90 ,
34+ // Share of stars rendered as "x" / "+" diffraction spikes instead of plain dots.
35+ starShapeChance : { x : 0.11 , plus : 0.11 } ,
36+ meteor : {
37+ minIntervalMs : 50 * 1000 ,
38+ maxIntervalMs : 180 * 1000 ,
39+ speedMinPxPerMs : 0.9 ,
40+ speedMaxPxPerMs : 1.6 ,
41+ lengthMin : 190 ,
42+ lengthMax : 1700 ,
43+ lineWidth : 2
44+ }
45+ } ;
46+
1647function randomBetween ( min : number , max : number ) : number {
1748 // Cosmetic randomness only (star position/timing) - not security-sensitive.
1849 return min + Math . random ( ) * ( max - min ) ; // NOSONAR
@@ -38,6 +69,26 @@ function alphaForStar(star: Star, age: number): number {
3869 return maxAlpha * ( 1 - easeInOut ( fadeOutAge / fadeOut ) ) ;
3970}
4071
72+ // Picks a realistic star color: mostly whitish, some blueish (hot stars like
73+ // Sirius/Rigel), some reddish (cool stars like Betelgeuse/Antares).
74+ function pickStarPalette ( ) : { hue : number ; sat : number } {
75+ const r = Math . random ( ) ;
76+ if ( r < 0.15 ) {
77+ return { hue : randomBetween ( 0 , 25 ) , sat : randomBetween ( 75 , 95 ) } ; // reddish
78+ }
79+ if ( r < 0.30 ) {
80+ return { hue : randomBetween ( 200 , 230 ) , sat : randomBetween ( 75 , 95 ) } ; // blueish
81+ }
82+ return { hue : randomBetween ( 40 , 60 ) , sat : randomBetween ( 5 , 20 ) } ; // whitish/neutral (majority)
83+ }
84+
85+ function pickStarShape ( ) : StarShape {
86+ const r = Math . random ( ) ;
87+ if ( r < CONFIG . starShapeChance . x ) return 'x' ;
88+ if ( r < CONFIG . starShapeChance . x + CONFIG . starShapeChance . plus ) return 'plus' ;
89+ return 'circle' ;
90+ }
91+
4192class StarfieldScreensaver {
4293 name = 'Starfield' ;
4394 type = PluginType . Screensaver ;
@@ -46,11 +97,13 @@ class StarfieldScreensaver {
4697
4798 private rafId : number | null = null ;
4899 private spawnTimeoutId : ReturnType < typeof setTimeout > | null = null ;
100+ private meteorTimeoutId : ReturnType < typeof setTimeout > | null = null ;
49101 private resizeHandler : ( ( ) => void ) | null = null ;
50102 private container : HTMLDivElement | null = null ;
51103 private canvas : HTMLCanvasElement | null = null ;
52104 private ctx : CanvasRenderingContext2D | null = null ;
53105 private stars : Star [ ] = [ ] ;
106+ private meteors : Meteor [ ] = [ ] ;
54107 private width = 0 ;
55108 private height = 0 ;
56109 private lastFrameTime = 0 ;
@@ -62,13 +115,16 @@ class StarfieldScreensaver {
62115 const fadeIn = randomBetween ( 600 , 1600 ) ;
63116 const hold = randomBetween ( 200 , 900 ) ;
64117 const fadeOut = randomBetween ( 800 , 2200 ) ;
118+ const palette = pickStarPalette ( ) ;
65119
66120 this . stars . push ( {
67121 x : randomBetween ( 0 , this . width ) ,
68122 y : randomBetween ( 0 , this . height ) ,
69- radius : randomBetween ( 0.6 , 1.8 ) ,
123+ radius : randomBetween ( 0.6 , 2.1 ) ,
70124 maxAlpha : randomBetween ( 0.35 , 1 ) ,
71- hue : randomBetween ( 200 , 220 ) ,
125+ hue : palette . hue ,
126+ sat : palette . sat ,
127+ shape : pickStarShape ( ) ,
72128 fadeIn,
73129 hold,
74130 fadeOut,
@@ -78,20 +134,159 @@ class StarfieldScreensaver {
78134 } ;
79135
80136 private readonly scheduleNextSpawn = ( delayOverride ?: number ) : void => {
81- const delay = delayOverride ?? randomBetween ( 80 , 260 ) ;
137+ // The spawn interval is derived from the desired star count: average
138+ // star lifetime (fadeIn+hold+fadeOut, ~3150ms on average) divided by
139+ // the target count. This keeps the steady-state population (new
140+ // stars balancing fading-out stars) actually close to
141+ // maxStarsNormal/maxStarsReducedMotion, instead of the limit having
142+ // no real effect.
143+ const maxConcurrent = this . prefersReducedMotion
144+ ? CONFIG . maxStarsReducedMotion
145+ : CONFIG . maxStarsNormal ;
146+ const avgStarLifetimeMs = 3150 ;
147+ const targetInterval = avgStarLifetimeMs / Math . max ( 1 , maxConcurrent ) ;
148+ const delay = delayOverride ?? randomBetween ( targetInterval * 0.5 , targetInterval * 1.5 ) ;
82149
83150 this . spawnTimeoutId = setTimeout ( ( ) => {
84151 this . spawnStar ( ) ;
152+ this . scheduleNextSpawn ( ) ;
153+ } , delay ) ;
154+ } ;
85155
86- const maxConcurrent = this . prefersReducedMotion ? 12 : 45 ;
87- if ( this . stars . length < maxConcurrent ) {
88- this . scheduleNextSpawn ( ) ;
89- } else {
90- this . scheduleNextSpawn ( randomBetween ( 200 , 400 ) ) ;
91- }
156+ private readonly spawnMeteor = ( ) : void => {
157+ if ( ! this . width || ! this . height ) return ;
158+
159+ // Meteor flies diagonally from top to bottom, sometimes coming from
160+ // the left, sometimes from the right.
161+ const angle = randomBetween ( 20 , 50 ) * ( Math . PI / 180 ) ;
162+ const dir = Math . random ( ) < 0.5 ? 1 : - 1 ;
163+ const speed = randomBetween ( CONFIG . meteor . speedMinPxPerMs , CONFIG . meteor . speedMaxPxPerMs ) ;
164+ const startX = dir === 1
165+ ? randomBetween ( - 100 , this . width * 0.4 )
166+ : randomBetween ( this . width * 0.6 , this . width + 100 ) ;
167+ const startY = randomBetween ( - 50 , this . height * 0.3 ) ;
168+
169+ this . meteors . push ( {
170+ x : startX ,
171+ y : startY ,
172+ vx : dir * speed * Math . cos ( angle ) ,
173+ vy : speed * Math . sin ( angle ) ,
174+ length : randomBetween ( CONFIG . meteor . lengthMin , CONFIG . meteor . lengthMax ) ,
175+ life : 0 ,
176+ maxLife : randomBetween ( 600 , 1000 ) ,
177+ hue : randomBetween ( 195 , 225 )
178+ } ) ;
179+ } ;
180+
181+ private readonly scheduleNextMeteor = ( ) : void => {
182+ const delay = randomBetween ( CONFIG . meteor . minIntervalMs , CONFIG . meteor . maxIntervalMs ) ;
183+ this . meteorTimeoutId = setTimeout ( ( ) => {
184+ this . spawnMeteor ( ) ;
185+ this . scheduleNextMeteor ( ) ;
92186 } , delay ) ;
93187 } ;
94188
189+ private readonly drawStar = ( star : Star , alpha : number ) : void => {
190+ const { ctx } = this ;
191+ if ( ! ctx ) return ;
192+
193+ ctx . save ( ) ;
194+ if ( star . maxAlpha > 0.7 ) {
195+ ctx . shadowBlur = star . radius * 5 ;
196+ ctx . shadowColor = `hsla(${ star . hue } , ${ star . sat } %, 88%, ${ alpha * 0.6 } )` ;
197+ }
198+ const color = `hsla(${ star . hue } , ${ star . sat } %, 88%, ${ alpha } )` ;
199+
200+ if ( star . shape === 'circle' ) {
201+ ctx . beginPath ( ) ;
202+ ctx . fillStyle = color ;
203+ ctx . arc ( star . x , star . y , star . radius , 0 , Math . PI * 2 ) ;
204+ ctx . fill ( ) ;
205+ } else {
206+ // "x"/"+" stars are drawn as soft diffraction spikes that fade
207+ // out towards the tip (similar to how bright stars look in
208+ // photos) rather than hard, evenly thick lines.
209+ const dirs : [ number , number ] [ ] = star . shape === 'x'
210+ ? [ [ 0.7071 , 0.7071 ] , [ 0.7071 , - 0.7071 ] , [ - 0.7071 , 0.7071 ] , [ - 0.7071 , - 0.7071 ] ]
211+ : [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] ;
212+ const tipDist = star . radius * 3.2 ;
213+ const baseWidth = Math . max ( 0.5 , star . radius * 0.9 ) ;
214+
215+ // Soft, bright core in the center.
216+ ctx . beginPath ( ) ;
217+ ctx . fillStyle = color ;
218+ ctx . arc ( star . x , star . y , star . radius * 0.7 , 0 , Math . PI * 2 ) ;
219+ ctx . fill ( ) ;
220+
221+ for ( const [ dx , dy ] of dirs ) {
222+ const tipX = star . x + dx * tipDist ;
223+ const tipY = star . y + dy * tipDist ;
224+ const perpX = - dy * ( baseWidth / 2 ) ;
225+ const perpY = dx * ( baseWidth / 2 ) ;
226+ const gradient = ctx . createLinearGradient ( star . x , star . y , tipX , tipY ) ;
227+ gradient . addColorStop ( 0 , color ) ;
228+ gradient . addColorStop ( 1 , `hsla(${ star . hue } , ${ star . sat } %, 88%, 0)` ) ;
229+
230+ ctx . beginPath ( ) ;
231+ ctx . moveTo ( star . x + perpX , star . y + perpY ) ;
232+ ctx . lineTo ( tipX , tipY ) ;
233+ ctx . lineTo ( star . x - perpX , star . y - perpY ) ;
234+ ctx . closePath ( ) ;
235+ ctx . fillStyle = gradient ;
236+ ctx . fill ( ) ;
237+ }
238+ }
239+ ctx . restore ( ) ;
240+ } ;
241+
242+ private readonly drawMeteors = ( dt : number ) : void => {
243+ const { ctx } = this ;
244+ if ( ! ctx ) return ;
245+
246+ for ( let i = this . meteors . length - 1 ; i >= 0 ; i -- ) {
247+ const meteor = this . meteors [ i ] ;
248+ meteor . life += dt ;
249+ meteor . x += meteor . vx * dt ;
250+ meteor . y += meteor . vy * dt ;
251+
252+ const lifeRatio = meteor . life / meteor . maxLife ;
253+ if (
254+ lifeRatio >= 1
255+ || meteor . x < - 200
256+ || meteor . x > this . width + 200
257+ || meteor . y > this . height + 200
258+ ) {
259+ this . meteors . splice ( i , 1 ) ;
260+ continue ;
261+ }
262+
263+ // Quick fade-in, then fade-out towards the end of the trajectory.
264+ const alpha = lifeRatio < 0.15
265+ ? lifeRatio / 0.15
266+ : ( lifeRatio > 0.7 ? Math . max ( 0 , 1 - ( lifeRatio - 0.7 ) / 0.3 ) : 1 ) ;
267+
268+ const angle = Math . atan2 ( meteor . vy , meteor . vx ) ;
269+ const tailX = meteor . x - Math . cos ( angle ) * meteor . length ;
270+ const tailY = meteor . y - Math . sin ( angle ) * meteor . length ;
271+
272+ const gradient = ctx . createLinearGradient ( meteor . x , meteor . y , tailX , tailY ) ;
273+ gradient . addColorStop ( 0 , `hsla(${ meteor . hue } , 70%, 95%, ${ alpha } )` ) ;
274+ gradient . addColorStop ( 1 , `hsla(${ meteor . hue } , 70%, 95%, 0)` ) ;
275+
276+ ctx . save ( ) ;
277+ ctx . strokeStyle = gradient ;
278+ ctx . lineWidth = CONFIG . meteor . lineWidth ;
279+ ctx . lineCap = 'round' ;
280+ ctx . shadowBlur = 8 ;
281+ ctx . shadowColor = `hsla(${ meteor . hue } , 70%, 95%, ${ alpha * 0.8 } )` ;
282+ ctx . beginPath ( ) ;
283+ ctx . moveTo ( meteor . x , meteor . y ) ;
284+ ctx . lineTo ( tailX , tailY ) ;
285+ ctx . stroke ( ) ;
286+ ctx . restore ( ) ;
287+ }
288+ } ;
289+
95290 private readonly draw = ( dt : number ) : void => {
96291 const { ctx } = this ;
97292 if ( ! ctx ) return ;
@@ -109,19 +304,10 @@ class StarfieldScreensaver {
109304 }
110305
111306 const alpha = alphaForStar ( star , star . age ) ;
112-
113- ctx . beginPath ( ) ;
114- ctx . fillStyle = `hsla(${ star . hue } , 60%, 90%, ${ alpha } )` ;
115- ctx . arc ( star . x , star . y , star . radius , 0 , Math . PI * 2 ) ;
116- ctx . fill ( ) ;
117-
118- if ( star . maxAlpha > 0.7 ) {
119- ctx . beginPath ( ) ;
120- ctx . fillStyle = `hsla(${ star . hue } , 60%, 90%, ${ alpha * 0.15 } )` ;
121- ctx . arc ( star . x , star . y , star . radius * 3 , 0 , Math . PI * 2 ) ;
122- ctx . fill ( ) ;
123- }
307+ this . drawStar ( star , alpha ) ;
124308 }
309+
310+ this . drawMeteors ( dt ) ;
125311 } ;
126312
127313 private readonly tick = ( now : number ) : void => {
@@ -179,6 +365,11 @@ class StarfieldScreensaver {
179365 this . spawnTimeoutId = null ;
180366 }
181367
368+ if ( this . meteorTimeoutId !== null ) {
369+ clearTimeout ( this . meteorTimeoutId ) ;
370+ this . meteorTimeoutId = null ;
371+ }
372+
182373 if ( this . resizeHandler ) {
183374 window . removeEventListener ( 'resize' , this . resizeHandler ) ;
184375 this . resizeHandler = null ;
@@ -195,7 +386,9 @@ class StarfieldScreensaver {
195386 window . addEventListener ( 'resize' , this . resizeHandler ) ;
196387
197388 this . stars = [ ] ;
389+ this . meteors = [ ] ;
198390 this . scheduleNextSpawn ( 0 ) ;
391+ this . scheduleNextMeteor ( ) ;
199392 this . lastFrameTime = performance . now ( ) ;
200393 this . rafId = requestAnimationFrame ( this . tick ) ;
201394 } ;
@@ -209,6 +402,7 @@ class StarfieldScreensaver {
209402 this . canvas = null ;
210403 this . ctx = null ;
211404 this . stars = [ ] ;
405+ this . meteors = [ ] ;
212406
213407 return Promise . resolve ( ) ;
214408 } ;
0 commit comments