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
This commit is contained in:
cottongin
2026-03-20 15:02:46 -04:00
parent d775f74035
commit 7a2f8419d5
2 changed files with 127 additions and 2 deletions

View File

@@ -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;
}
}
}

View File

@@ -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 {