Skip to content

Commit 147c1ed

Browse files
authored
Update plugin.ts
added colors and shapes
1 parent e1f1ade commit 147c1ed

1 file changed

Lines changed: 215 additions & 21 deletions

File tree

src/plugins/starfieldScreensaver/plugin.ts

Lines changed: 215 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,49 @@
11
import { PluginType } from 'constants/pluginType';
22

3+
type StarShape = 'circle' | 'x' | 'plus';
4+
35
interface 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+
1647
function 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+
4192
class 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

Comments
 (0)