Add horizon beat pulse, spectrum visualizer, meteor shower, theme color

- Horizon flashes brighter on each beat (synced with character bounce)
- Spectrum visualizer behind the character shows mid/high frequencies
- Meteors streak across the sky randomly every 4-10s
- fetchTheme() pulls dominant color from music-light and tints horizon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-06-07 10:08:15 -03:00
parent e858583964
commit ad81c6fb69
No known key found for this signature in database
GPG key ID: FB9FDAB6B6F3418D

View file

@ -96,6 +96,54 @@ html, body {
0%, 100% { opacity: 0.75; } 0%, 100% { opacity: 0.75; }
50% { opacity: 1; } 50% { opacity: 1; }
} }
@keyframes horizonBeat {
0% { opacity: 1; box-shadow: 0 0 60px var(--theme-color, rgba(0,220,255,1)), 0 0 140px var(--theme-color, rgba(0,200,255,0.6)), 0 0 8px #fff; filter: brightness(2.5); }
100% { opacity: 0.75; box-shadow: 0 0 25px rgba(0,220,255,0.8), 0 0 80px rgba(0,200,255,0.25); filter: brightness(1); }
}
.horizon.beat {
animation: horizonBeat 0.45s ease-out forwards;
}
/* ============================================================
SPECTRUM VISUALIZER (behind character)
============================================================ */
.spectrum {
position: absolute;
bottom: 32%;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: 3px;
pointer-events: none;
opacity: 0;
transition: opacity 0.8s;
mix-blend-mode: screen;
}
.spec-bar {
width: 6px;
border-radius: 3px 3px 0 0;
height: 3px;
transition: height 0.05s linear;
}
/* ============================================================
METEORS
============================================================ */
.meteor {
position: absolute;
width: 2px;
border-radius: 1px;
pointer-events: none;
transform-origin: top center;
animation: meteorFall linear forwards;
}
@keyframes meteorFall {
0% { opacity: 0; }
5% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
/* ============================================================ /* ============================================================
VU METER (moved up) VU METER (moved up)
@ -714,6 +762,9 @@ html, body {
<!-- Horizon glow --> <!-- Horizon glow -->
<div class="horizon"></div> <div class="horizon"></div>
<!-- Spectrum visualizer (behind character) -->
<div class="spectrum" id="spectrum"></div>
<!-- ===================== ROBOT ===================== --> <!-- ===================== ROBOT ===================== -->
<div class="robot-wrap"> <div class="robot-wrap">
<div class="beat-wrap"> <div class="beat-wrap">
@ -867,6 +918,63 @@ window.addEventListener('resize', resizeFloor);
resizeFloor(); resizeFloor();
requestAnimationFrame(drawFloor); requestAnimationFrame(drawFloor);
/* ============================================================
SPECTRUM VISUALIZER
============================================================ */
const SPEC_COUNT = 48;
const specEl = document.getElementById('spectrum');
const specBars = [];
const SPEC_PALETTE = [
'#ff0099','#ff00cc','#cc00ff','#9900ff','#6600ff',
'#0033ff','#0066ff','#0099ff','#00ccff','#00ffff',
'#00ffcc','#00ff99','#00ff66','#00ff33',
'#33ff00','#66ff00','#99ff00','#ccff00',
'#ffff00','#ffcc00','#ff9900','#ff6600','#ff3300','#ff0000',
];
for (let i = 0; i < SPEC_COUNT; i++) {
const b = document.createElement('div');
b.className = 'spec-bar';
const c = SPEC_PALETTE[Math.round(i / SPEC_COUNT * (SPEC_PALETTE.length - 1))];
b.style.background = c;
b.style.boxShadow = `0 0 6px ${c}`;
specEl.appendChild(b);
specBars.push(b);
}
/* ============================================================
METEORS
============================================================ */
const scene = document.querySelector('.scene');
function spawnMeteor() {
const el = document.createElement('div');
el.className = 'meteor';
const x = Math.random() * 110 - 5; // % from left (can go off-edge)
const angle = 25 + Math.random() * 20; // degrees
const len = 80 + Math.random() * 140; // px
const dur = 1.2 + Math.random() * 1.8; // seconds
const color = `rgba(${180+Math.random()*75|0},${180+Math.random()*75|0},255,0.9)`;
el.style.cssText = `
left:${x}%; top:${-len}px;
height:${len}px;
background:linear-gradient(to bottom, transparent, ${color});
transform:rotate(${angle}deg);
animation-duration:${dur}s;
animation-name:meteorFall;
translate:0 ${(window.innerHeight + len) * 1.3}px;
`;
scene.appendChild(el);
setTimeout(() => el.remove(), dur * 1000);
}
// Spawn 1-2 meteors every 4-10 seconds
(function meteorLoop() {
spawnMeteor();
if (Math.random() < 0.3) setTimeout(spawnMeteor, 300);
setTimeout(meteorLoop, 4000 + Math.random() * 6000);
})();
/* ============================================================ /* ============================================================
@ -906,18 +1014,25 @@ const BASS_BARS = 4;
const BEAT_THRESH = 1.5; const BEAT_THRESH = 1.5;
const BEAT_COOLDOWN = 280; const BEAT_COOLDOWN = 280;
let bassSmooth = 0, bassEma = 0, lastBeat = 0; let bassSmooth = 0, bassEma = 0, lastBeat = 0;
const beatWrap = document.querySelector('.beat-wrap'); const beatWrap = document.querySelector('.beat-wrap');
const horizonEl = document.querySelector('.horizon');
function triggerBeat() {
[beatWrap, horizonEl].forEach(el => {
el.classList.remove('beat');
void el.offsetWidth;
el.classList.add('beat');
});
}
function detectBeat(vals) { function detectBeat(vals) {
const bass = vals.slice(0, BASS_BARS).reduce((s, v) => s + v, 0) / BASS_BARS; const bass = vals.slice(0, BASS_BARS).reduce((s, v) => s + v, 0) / BASS_BARS;
bassSmooth = bassSmooth * 0.6 + bass * 0.4; // fast smooth bassSmooth = bassSmooth * 0.6 + bass * 0.4;
bassEma = bassEma * 0.96 + bass * 0.04; // slow background bassEma = bassEma * 0.96 + bass * 0.04;
const now = performance.now(); const now = performance.now();
if (bassSmooth > bassEma * BEAT_THRESH && bassSmooth > 8 && now - lastBeat > BEAT_COOLDOWN) { if (bassSmooth > bassEma * BEAT_THRESH && bassSmooth > 8 && now - lastBeat > BEAT_COOLDOWN) {
lastBeat = now; lastBeat = now;
beatWrap.classList.remove('beat'); triggerBeat();
void beatWrap.offsetWidth; // force reflow to restart animation
beatWrap.classList.add('beat');
} }
} }
@ -926,12 +1041,45 @@ function updateBars(raw) {
if (!isPlaying) return; if (!isPlaying) return;
const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0)); const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
detectBeat(vals); detectBeat(vals);
// VU meter (low bars)
vuBars.forEach((bar, i) => { vuBars.forEach((bar, i) => {
const v = vals[i] || 0; const v = vals[i] || 0;
const h = Math.max(3, (v / 100) * MAX_BAR_H); bar.style.height = Math.max(3, (v / 100) * MAX_BAR_H) + 'px';
bar.style.height = h + 'px';
bar.style.opacity = 0.3 + 0.7 * (v / 100); bar.style.opacity = 0.3 + 0.7 * (v / 100);
}); });
// Spectrum (mid-to-high frequencies, remapped to SPEC_COUNT bars)
specBars.forEach((bar, i) => {
const srcIdx = Math.round(4 + (i / SPEC_COUNT) * (vals.length - 5));
const v = vals[srcIdx] || 0;
bar.style.height = Math.max(3, (v / 100) * window.innerHeight * 0.55) + 'px';
});
}
/* ============================================================
MUSIC-LIGHT THEME (album color)
============================================================ */
function applyTheme(rgb) {
const [r, g, b] = rgb;
const color = `rgba(${r},${g},${b},1)`;
document.documentElement.style.setProperty('--theme-color', color);
// Tint horizon
horizonEl.style.background = `linear-gradient(to right,
transparent 0%,
rgba(${r},${g},${b},0.7) 20%,
rgba(${r},${g},${b},1) 50%,
rgba(${r},${g},${b},0.7) 80%,
transparent 100%)`;
horizonEl.style.boxShadow =
`0 0 30px rgba(${r},${g},${b},0.9), 0 0 90px rgba(${r},${g},${b},0.3)`;
}
function fetchTheme() {
fetch('http://127.0.0.1:8765/current')
.then(r => r.json())
.then(d => { if (d.rgb) applyTheme(d.rgb); })
.catch(() => {});
} }
// Connect to CAVA SSE server (started by cava-start.sh via QML) // Connect to CAVA SSE server (started by cava-start.sh via QML)
@ -985,7 +1133,9 @@ function updateMusic(artist, title) {
isPlaying = playing; isPlaying = playing;
document.querySelector('.robot-wrap').style.opacity = playing ? '1' : '0'; document.querySelector('.robot-wrap').style.opacity = playing ? '1' : '0';
document.querySelector('.music-ticker').style.opacity = playing ? '1' : '0'; document.querySelector('.music-ticker').style.opacity = playing ? '1' : '0';
specEl.style.opacity = playing ? '0.45' : '0';
if (!playing) vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; }); if (!playing) vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; });
if (playing) fetchTheme();
if (el.textContent === text) return; if (el.textContent === text) return;
el.textContent = text; el.textContent = text;
if (playing) pickSkeleton(); if (playing) pickSkeleton();