Add beat-synced bounce effect to character

Detects bass beats from CAVA data using a fast/slow EMA ratio.
On each beat, applies a CSS scale+translateY animation to .beat-wrap
without interfering with the APNG playback or positioning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-06-07 09:49:17 -03:00
parent d0f72716c2
commit 85726b8c41
No known key found for this signature in database
GPG key ID: FB9FDAB6B6F3418D

View file

@ -217,6 +217,19 @@ html, body {
transition: opacity 0.8s;
}
.beat-wrap {
display: block;
transform-origin: center bottom;
}
@keyframes beatBounce {
0% { transform: scale(1.08) translateY(-10px); filter: brightness(1.4); }
55% { transform: scale(0.96) translateY(4px); filter: brightness(1); }
100% { transform: scale(1) translateY(0); filter: brightness(1); }
}
.beat-wrap.beat {
animation: beatBounce 0.35s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
.skeleton-gif {
display: block;
height: 40vh;
@ -703,8 +716,10 @@ html, body {
<!-- ===================== ROBOT ===================== -->
<div class="robot-wrap">
<div class="beat-wrap">
<img src="skeleton4.apng" class="skeleton-gif" alt="skeleton">
<div class="robot-shadow"></div>
</div>
</div><!-- .robot-wrap -->
<!-- VU Meter -->
@ -886,12 +901,33 @@ for (let i = 0; i < VU_COUNT; i++) {
vuBars.push(bar);
}
// Beat detection
const BASS_BARS = 4;
const BEAT_THRESH = 1.5;
const BEAT_COOLDOWN = 280;
let bassSmooth = 0, bassEma = 0, lastBeat = 0;
const beatWrap = document.querySelector('.beat-wrap');
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
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');
}
}
// Called with semicolon-separated CAVA values (0100 per bar)
function updateBars(raw) {
if (!isPlaying) return;
const vals = raw.split(';');
const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
detectBeat(vals);
vuBars.forEach((bar, i) => {
const v = Math.min(100, parseInt(vals[i] || 0, 10));
const v = vals[i] || 0;
const h = Math.max(3, (v / 100) * MAX_BAR_H);
bar.style.height = h + 'px';
bar.style.opacity = 0.3 + 0.7 * (v / 100);