From 7a2f8419d520b5cafe2308891348c98455685041 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 15:02:46 -0400 Subject: [PATCH] feat: add animated gradient meter to header text based on player count CSS background-clip: text with dynamic linear-gradient replaces flat header color. Fill sweeps left-to-right (pink to white) proportional to playerCount/maxPlayers. Pulse glow fires when lobby is full. Made-with: Cursor --- js/room-code-display.js | 113 +++++++++++++++++++++++++++++++++++++++- optimized-controls.html | 16 +++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/js/room-code-display.js b/js/room-code-display.js index 63c37a7..0b237d5 100644 --- a/js/room-code-display.js +++ b/js/room-code-display.js @@ -56,6 +56,18 @@ export class RoomCodeDisplay { /** @type {boolean} */ #active = false; + /** @type {number} */ + #meterFill = 0; + + /** @type {number} */ + #meterTarget = 0; + + /** @type {number | null} */ + #meterRafId = null; + + /** @type {boolean} */ + #meterPulseFired = false; + /** * @param {RoomCodeDisplayElements} elements * @param {RoomCodeDisplayInputs} inputs @@ -89,6 +101,15 @@ export class RoomCodeDisplay { } this.#applySettings(); + + const count = Number(ctx?.playerCount ?? 0); + const max = Number(ctx?.maxPlayers ?? 0); + const initialFill = max > 0 ? count / max : 0; + this.#meterFill = Math.max(0, Math.min(1, initialFill)); + this.#meterTarget = this.#meterFill; + this.#meterPulseFired = false; + this.#applyMeterGradient(); + this.#startAnimation(); } @@ -96,6 +117,14 @@ export class RoomCodeDisplay { this.#active = false; this.#clearAnimationTimers(); + if (this.#meterRafId != null) { + cancelAnimationFrame(this.#meterRafId); + this.#meterRafId = null; + } + this.#meterFill = 0; + this.#meterTarget = 0; + this.#meterPulseFired = false; + const { header, footer, codePart1, codePart2 } = this.#elements ?? {}; if (!header || !footer || !codePart1 || !codePart2) { return; @@ -127,6 +156,14 @@ export class RoomCodeDisplay { if (next && next !== current) { this.activate(ctx); + return; + } + + const count = Number(ctx?.playerCount ?? 0); + const max = Number(ctx?.maxPlayers ?? 0); + const newTarget = max > 0 ? count / max : 0; + if (Math.abs(newTarget - this.#meterTarget) > 0.001) { + this.#animateMeterTo(newTarget); } } @@ -140,6 +177,7 @@ export class RoomCodeDisplay { active: this.#active, roomCode, timersRunning: this.#animationTimers.length > 0, + meterFill: Math.round(this.#meterFill * 100), }; } @@ -175,7 +213,7 @@ export class RoomCodeDisplay { codePart2.style.fontSize = `${inputs.size.value}px`; header.textContent = inputs.headerText.value; - header.style.color = inputs.headerColor.value; + this.#applyMeterGradient(); header.style.fontSize = `${inputs.headerSize.value}px`; header.style.transform = `translateY(${inputs.headerOffset.value}px)`; @@ -279,4 +317,77 @@ export class RoomCodeDisplay { }, cycleDuration), ); } + + #applyMeterGradient() { + const header = this.#elements?.header; + const inputs = this.#inputs; + if (!header || !inputs) return; + + const pct = Math.round(this.#meterFill * 100); + const baseColor = inputs.headerColor.value || '#f35dcb'; + + header.style.background = + `linear-gradient(to right, #ffffff 0%, #ffffff ${pct}%, ${baseColor} ${pct}%, ${baseColor} 100%)`; + header.style.webkitBackgroundClip = 'text'; + header.style.backgroundClip = 'text'; + header.style.webkitTextFillColor = 'transparent'; + } + + #animateMeterTo(target) { + const clamped = Math.max(0, Math.min(1, target)); + this.#meterTarget = clamped; + + if (this.#meterRafId != null) { + cancelAnimationFrame(this.#meterRafId); + this.#meterRafId = null; + } + + const start = this.#meterFill; + const delta = clamped - start; + if (Math.abs(delta) < 0.001) { + this.#meterFill = clamped; + this.#applyMeterGradient(); + this.#checkFullPulse(); + return; + } + + const duration = 400; + const startTime = performance.now(); + + const step = (now) => { + const elapsed = now - startTime; + const t = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - t, 3); + this.#meterFill = start + delta * eased; + this.#applyMeterGradient(); + + if (t < 1) { + this.#meterRafId = requestAnimationFrame(step); + } else { + this.#meterRafId = null; + this.#meterFill = clamped; + this.#applyMeterGradient(); + this.#checkFullPulse(); + } + }; + + this.#meterRafId = requestAnimationFrame(step); + } + + #checkFullPulse() { + const header = this.#elements?.header; + if (!header) return; + + if (this.#meterFill >= 1 && !this.#meterPulseFired) { + this.#meterPulseFired = true; + header.classList.add('meter-full-pulse'); + header.addEventListener('animationend', () => { + header.classList.remove('meter-full-pulse'); + }, { once: true }); + } + + if (this.#meterFill < 1) { + this.#meterPulseFired = false; + } + } } diff --git a/optimized-controls.html b/optimized-controls.html index 6b52866..9b28d4b 100644 --- a/optimized-controls.html +++ b/optimized-controls.html @@ -35,10 +35,24 @@ color: #f35dcb; font-size: 80px; font-weight: bold; - text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.8); letter-spacing: 2px; opacity: 0; transition: opacity 0.5s ease; + background: linear-gradient(to right, #ffffff 0%, #ffffff 0%, #f35dcb 0%, #f35dcb 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + filter: drop-shadow(3px 3px 8px rgba(0, 0, 0, 0.8)); + } + + @keyframes meter-full-pulse { + 0% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); } + 50% { filter: drop-shadow(0 0 20px rgba(255,255,255,0.6)) drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); } + 100% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); } + } + + #header.meter-full-pulse { + animation: meter-full-pulse 0.6s ease-out; } #footer {