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:
parent
e858583964
commit
ad81c6fb69
1 changed files with 159 additions and 9 deletions
|
|
@ -96,6 +96,54 @@ html, body {
|
|||
0%, 100% { opacity: 0.75; }
|
||||
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)
|
||||
|
|
@ -714,6 +762,9 @@ html, body {
|
|||
<!-- Horizon glow -->
|
||||
<div class="horizon"></div>
|
||||
|
||||
<!-- Spectrum visualizer (behind character) -->
|
||||
<div class="spectrum" id="spectrum"></div>
|
||||
|
||||
<!-- ===================== ROBOT ===================== -->
|
||||
<div class="robot-wrap">
|
||||
<div class="beat-wrap">
|
||||
|
|
@ -867,6 +918,63 @@ window.addEventListener('resize', resizeFloor);
|
|||
resizeFloor();
|
||||
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);
|
||||
})();
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
|
|
@ -907,17 +1015,24 @@ const BEAT_THRESH = 1.5;
|
|||
const BEAT_COOLDOWN = 280;
|
||||
let bassSmooth = 0, bassEma = 0, lastBeat = 0;
|
||||
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) {
|
||||
const bass = vals.slice(0, BASS_BARS).reduce((s, v) => s + v, 0) / BASS_BARS;
|
||||
bassSmooth = bassSmooth * 0.6 + bass * 0.4; // fast smooth
|
||||
bassEma = bassEma * 0.96 + bass * 0.04; // slow background
|
||||
bassSmooth = bassSmooth * 0.6 + bass * 0.4;
|
||||
bassEma = bassEma * 0.96 + bass * 0.04;
|
||||
const now = performance.now();
|
||||
if (bassSmooth > bassEma * BEAT_THRESH && bassSmooth > 8 && now - lastBeat > BEAT_COOLDOWN) {
|
||||
lastBeat = now;
|
||||
beatWrap.classList.remove('beat');
|
||||
void beatWrap.offsetWidth; // force reflow to restart animation
|
||||
beatWrap.classList.add('beat');
|
||||
triggerBeat();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -926,12 +1041,45 @@ function updateBars(raw) {
|
|||
if (!isPlaying) return;
|
||||
const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
|
||||
detectBeat(vals);
|
||||
|
||||
// VU meter (low bars)
|
||||
vuBars.forEach((bar, i) => {
|
||||
const v = vals[i] || 0;
|
||||
const h = Math.max(3, (v / 100) * MAX_BAR_H);
|
||||
bar.style.height = h + 'px';
|
||||
bar.style.height = Math.max(3, (v / 100) * MAX_BAR_H) + 'px';
|
||||
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)
|
||||
|
|
@ -985,7 +1133,9 @@ function updateMusic(artist, title) {
|
|||
isPlaying = playing;
|
||||
document.querySelector('.robot-wrap').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) fetchTheme();
|
||||
if (el.textContent === text) return;
|
||||
el.textContent = text;
|
||||
if (playing) pickSkeleton();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue