From f10916532e92c18fbc62c796ce5ce3d275a99b81 Mon Sep 17 00:00:00 2001 From: tikaiz Date: Wed, 8 Apr 2026 09:46:07 +0200 Subject: [PATCH] Add lifetime to particles --- .../interactive-background.ts | 162 +++++++++++++++--- 1 file changed, 136 insertions(+), 26 deletions(-) diff --git a/src/components/interactive-background/interactive-background.ts b/src/components/interactive-background/interactive-background.ts index 3f5c14b..5ac1e95 100644 --- a/src/components/interactive-background/interactive-background.ts +++ b/src/components/interactive-background/interactive-background.ts @@ -14,10 +14,24 @@ type Particle = { vy: number; size: number; alpha: number; + ageMs: number; + lifetimeMs: number; + respawnDelayMs: number; }; class ParticleEngine { + private readonly connectionDistanceSq = 19600; + private readonly densityRadiusSq = 4900; + private readonly densityFadeStart = 6; + private readonly densityFadeRange = 10; + private readonly maxDensityFade = 0.75; + private readonly minLifetimeMs = 9000; + private readonly maxLifetimeMs = 24000; + private readonly minRespawnDelayMs = 140; + private readonly maxRespawnDelayMs = 900; private readonly particles: Particle[] = []; + private readonly localDensity: number[] = []; + private readonly lifeAlpha: number[] = []; private width = 0; private height = 0; private dpr = 1; @@ -25,14 +39,19 @@ class ParticleEngine { constructor(private readonly ctx: CanvasRenderingContext2D, particleCount: number) { for (let i = 0; i < particleCount; i += 1) { - this.particles.push({ + const particle: Particle = { x: Math.random(), y: Math.random(), - vx: (Math.random() - 0.5) * 0.18, - vy: (Math.random() - 0.5) * 0.18, - size: 1 + Math.random() * 2.2, - alpha: 0.35 + Math.random() * 0.5, - }); + vx: (Math.random() - 0.5) * 0.24, + vy: (Math.random() - 0.5) * 0.24, + size: 1.2 + Math.random() * 2.8, + alpha: 0.5 + Math.random() * 0.45, + ageMs: 0, + lifetimeMs: this.randomLifetimeMs(), + respawnDelayMs: 0, + }; + this.resetParticle(particle, false); + this.particles.push(particle); } } @@ -47,8 +66,9 @@ class ParticleEngine { } renderFrame(deltaMs: number): void { - const step = Math.min(deltaMs, 40) / 16.6667; - this.update(step); + const clampedDeltaMs = Math.min(deltaMs, 40); + const step = clampedDeltaMs / 16.6667; + this.update(step, clampedDeltaMs); this.draw(); } @@ -56,15 +76,29 @@ class ParticleEngine { this.draw(); } - private update(step: number): void { + private update(step: number, deltaMs: number): void { if (this.width === 0 || this.height === 0) { return; } - const influenceRadius = Math.min(this.width, this.height) * 0.22; + const influenceRadius = Math.min(this.width, this.height) * 0.3; const influenceRadiusSq = influenceRadius * influenceRadius; for (const particle of this.particles) { + if (particle.respawnDelayMs > 0) { + particle.respawnDelayMs = Math.max(0, particle.respawnDelayMs - deltaMs); + if (particle.respawnDelayMs === 0) { + this.resetParticle(particle, false); + } + continue; + } + + particle.ageMs += deltaMs; + if (particle.ageMs >= particle.lifetimeMs) { + particle.respawnDelayMs = this.randomRespawnDelayMs(); + continue; + } + const px = particle.x * this.width; const py = particle.y * this.height; @@ -76,16 +110,16 @@ class ParticleEngine { if (distanceSq > 1 && distanceSq < influenceRadiusSq) { const distance = Math.sqrt(distanceSq); const normalized = 1 - distance / influenceRadius; - const direction = this.pointer.pressed ? -1 : 1; - const force = normalized * 0.02 * direction; + const directionBoost = this.pointer.pressed ? -2.2 : 1.35; + const force = normalized * 0.033 * directionBoost; particle.vx += (dx / distance) * force * step; particle.vy += (dy / distance) * force * step; } } - particle.vx *= 0.985; - particle.vy *= 0.985; + particle.vx *= 0.978; + particle.vy *= 0.978; particle.x += (particle.vx * step) / this.width; particle.y += (particle.vy * step) / this.height; @@ -118,27 +152,41 @@ class ParticleEngine { Math.max(this.width, this.height) ); gradient.addColorStop(0, 'rgba(88, 166, 255, 0.16)'); - gradient.addColorStop(0.5, 'rgba(147, 102, 255, 0.08)'); + gradient.addColorStop(0.4, 'rgba(88, 166, 255, 0.2)'); + gradient.addColorStop(0.7, 'rgba(147, 102, 255, 0.12)'); gradient.addColorStop(1, 'rgba(13, 17, 23, 0)'); this.ctx.fillStyle = gradient; this.ctx.fillRect(0, 0, this.width, this.height); - for (const particle of this.particles) { - const x = particle.x * this.width; - const y = particle.y * this.height; - this.ctx.beginPath(); - this.ctx.arc(x, y, particle.size, 0, Math.PI * 2); - this.ctx.fillStyle = `rgba(230, 237, 243, ${particle.alpha})`; - this.ctx.fill(); + if (this.localDensity.length !== this.particles.length) { + this.localDensity.length = this.particles.length; + } + if (this.lifeAlpha.length !== this.particles.length) { + this.lifeAlpha.length = this.particles.length; + } + this.localDensity.fill(0); + + for (let i = 0; i < this.particles.length; i += 1) { + this.lifeAlpha[i] = this.getLifeAlpha(this.particles[i]); } this.ctx.lineWidth = 1; for (let i = 0; i < this.particles.length; i += 1) { const a = this.particles[i]; + const lifeA = this.lifeAlpha[i]; + if (lifeA <= 0.01) { + continue; + } + for (let j = i + 1; j < this.particles.length; j += 1) { const b = this.particles[j]; + const lifeB = this.lifeAlpha[j]; + if (lifeB <= 0.01) { + continue; + } + const ax = a.x * this.width; const ay = a.y * this.height; const bx = b.x * this.width; @@ -147,9 +195,14 @@ class ParticleEngine { const dy = ay - by; const distSq = dx * dx + dy * dy; - if (distSq < 10000) { - const alpha = 1 - distSq / 10000; - this.ctx.strokeStyle = `rgba(88, 166, 255, ${alpha * 0.2})`; + if (distSq < this.densityRadiusSq) { + this.localDensity[i] += 1; + this.localDensity[j] += 1; + } + + if (distSq < this.connectionDistanceSq) { + const alpha = 1 - distSq / this.connectionDistanceSq; + this.ctx.strokeStyle = `rgba(106, 194, 255, ${alpha * 0.38 * Math.min(lifeA, lifeB)})`; this.ctx.beginPath(); this.ctx.moveTo(ax, ay); this.ctx.lineTo(bx, by); @@ -157,6 +210,61 @@ class ParticleEngine { } } } + + for (let i = 0; i < this.particles.length; i += 1) { + const particle = this.particles[i]; + const x = particle.x * this.width; + const y = particle.y * this.height; + const crowding = Math.min( + Math.max((this.localDensity[i] - this.densityFadeStart) / this.densityFadeRange, 0), + 1 + ); + const effectiveAlpha = Math.max( + particle.alpha * this.lifeAlpha[i] * (1 - crowding * this.maxDensityFade), + 0.02 + ); + + if (effectiveAlpha <= 0.021) { + continue; + } + + this.ctx.beginPath(); + this.ctx.arc(x, y, particle.size, 0, Math.PI * 2); + this.ctx.fillStyle = `rgba(222, 244, 255, ${effectiveAlpha})`; + this.ctx.fill(); + } + } + + private randomLifetimeMs(): number { + return this.minLifetimeMs + Math.random() * (this.maxLifetimeMs - this.minLifetimeMs); + } + + private randomRespawnDelayMs(): number { + return this.minRespawnDelayMs + Math.random() * (this.maxRespawnDelayMs - this.minRespawnDelayMs); + } + + private resetParticle(particle: Particle, withDelay: boolean): void { + particle.x = Math.random(); + particle.y = Math.random(); + particle.vx = (Math.random() - 0.5) * 0.24; + particle.vy = (Math.random() - 0.5) * 0.24; + particle.size = 1.2 + Math.random() * 2.8; + particle.alpha = 0.5 + Math.random() * 0.45; + particle.ageMs = 0; + particle.lifetimeMs = this.randomLifetimeMs(); + particle.respawnDelayMs = withDelay ? this.randomRespawnDelayMs() : 0; + } + + private getLifeAlpha(particle: Particle): number { + if (particle.respawnDelayMs > 0 || particle.lifetimeMs <= 0) { + return 0; + } + + const progress = Math.min(Math.max(particle.ageMs / particle.lifetimeMs, 0), 1); + const fadeIn = Math.min(progress / 0.25, 1); + const fadeOut = Math.min((1 - progress) / 0.32, 1); + + return Math.min(fadeIn, fadeOut, 1); } } @@ -192,7 +300,9 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { this.reducedMotion = this.mediaQuery.matches; if (context) { - const particleCount = this.reducedMotion ? 32 : 88; + const area = window.innerWidth * window.innerHeight; + const dynamicCount = Math.round(Math.min(170, Math.max(80, area / 14500))); + const particleCount = this.reducedMotion ? 42 : dynamicCount; this.particleEngine = new ParticleEngine(context, particleCount); this.resizeCanvas(); this.renderInitialFrame();