Rain and globe rAF loops now start on demand and stop when their effect fully fades, instead of ticking every frame forever. Lightning clears the bluish strobe tint after each burst. Removed triggerStrobe, which was disabled and unreferenced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015HtPHsTnDcLVSCiF5FGHZj
2193 lines
67 KiB
HTML
2193 lines
67 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
|
||
/* ============================================================
|
||
RESET + BASE
|
||
============================================================ */
|
||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
html, body {
|
||
width: 100%; height: 100%;
|
||
overflow: hidden;
|
||
background: #000814;
|
||
user-select: none;
|
||
}
|
||
|
||
.scene {
|
||
position: relative;
|
||
width: 100%; height: 100%;
|
||
}
|
||
|
||
/* ============================================================
|
||
BACKGROUND: NEBULA
|
||
============================================================ */
|
||
.nebula {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(80px);
|
||
pointer-events: none;
|
||
animation: nebulaFloat ease-in-out infinite;
|
||
}
|
||
.nebula::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
opacity: 0;
|
||
transition: opacity 9s ease;
|
||
}
|
||
body.balada .nebula::after { opacity: 1; }
|
||
.nebula-a {
|
||
width: 500px; height: 350px;
|
||
background: radial-gradient(ellipse, rgba(120,0,220,0.18), transparent 70%);
|
||
top: 5%; left: 5%;
|
||
animation-duration: 9s;
|
||
}
|
||
.nebula-b {
|
||
width: 420px; height: 300px;
|
||
background: radial-gradient(ellipse, rgba(0,80,200,0.15), transparent 70%);
|
||
top: 15%; right: 8%;
|
||
animation-duration: 12s;
|
||
animation-direction: reverse;
|
||
}
|
||
.nebula-c {
|
||
width: 300px; height: 200px;
|
||
background: radial-gradient(ellipse, rgba(200,0,100,0.1), transparent 70%);
|
||
bottom: 40%; left: 20%;
|
||
animation-duration: 7s;
|
||
animation-delay: -3s;
|
||
}
|
||
@keyframes nebulaFloat {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
50% { transform: translate(25px, -20px) scale(1.05); }
|
||
}
|
||
|
||
/* ============================================================
|
||
BACKGROUND: STARFIELD CANVAS
|
||
============================================================ */
|
||
#starfield {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ============================================================
|
||
RETROWAVE GRID FLOOR
|
||
============================================================ */
|
||
#floorCanvas {
|
||
position: absolute;
|
||
bottom: 0; left: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Horizon glow line */
|
||
.horizon {
|
||
position: absolute;
|
||
bottom: 41.5%;
|
||
left: 0; right: 0;
|
||
height: 3px;
|
||
background: linear-gradient(
|
||
to right,
|
||
transparent 0%,
|
||
rgba(255, 0, 150, 0.9) 20%,
|
||
rgba(0, 220, 255, 1) 50%,
|
||
rgba(255, 0, 150, 0.9) 80%,
|
||
transparent 100%
|
||
);
|
||
box-shadow: 0 0 25px rgba(0,220,255,0.8), 0 0 80px rgba(0,200,255,0.25);
|
||
animation: horizonPulse 1s ease-in-out infinite;
|
||
}
|
||
@keyframes horizonPulse {
|
||
0%, 100% { opacity: 0.75; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
/* ============================================================
|
||
SPECTRUM VISUALIZER (behind character)
|
||
============================================================ */
|
||
.spec-bar-unused {
|
||
}
|
||
|
||
/* ============================================================
|
||
METEORS
|
||
============================================================ */
|
||
.meteor {
|
||
position: absolute;
|
||
width: 2px;
|
||
border-radius: 1px;
|
||
pointer-events: none;
|
||
transform-origin: top center;
|
||
animation: meteorFall linear forwards;
|
||
}
|
||
#meteorLayer {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
-webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 60%);
|
||
mask-image: linear-gradient(to bottom, black 50%, transparent 60%);
|
||
}
|
||
|
||
@keyframes meteorFall {
|
||
0% { opacity: 0; transform: rotate(var(--rot)) translateY(0); }
|
||
5% { opacity: 1; }
|
||
85% { opacity: 1; }
|
||
100% { opacity: 0; transform: rotate(var(--rot)) translateY(var(--dist)); }
|
||
}
|
||
|
||
/* ============================================================
|
||
VU METER (moved up)
|
||
============================================================ */
|
||
.vu-meter {
|
||
position: absolute;
|
||
bottom: 5%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 4px;
|
||
pointer-events: none;
|
||
transition: opacity 0.8s;
|
||
}
|
||
.vu-bar {
|
||
width: 9px;
|
||
min-height: 3px;
|
||
border-radius: 3px 3px 0 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
MUSIC TICKER
|
||
============================================================ */
|
||
.album-art {
|
||
position: absolute;
|
||
top: 4%;
|
||
right: 2%;
|
||
width: 330px;
|
||
height: 330px;
|
||
border-radius: 6px;
|
||
box-shadow: 0 0 18px rgba(0,0,0,0.8), 0 0 8px var(--theme-color, rgba(0,220,255,0.5));
|
||
transition: opacity 0.6s ease, box-shadow 0.6s ease;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
.album-art img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.music-ticker {
|
||
position: absolute;
|
||
top: 4%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
max-width: 80vw;
|
||
overflow: hidden;
|
||
opacity: 0;
|
||
transition: opacity 0.8s, top 0.8s ease;
|
||
}
|
||
body.balada .music-ticker {
|
||
/* acima do VU meter: bottom 5% + 70px barras + 10px gap */
|
||
top: calc(100% - 5% - 70px - 10px - 28px);
|
||
}
|
||
.music-icon {
|
||
color: #ff44aa;
|
||
font-size: 18px;
|
||
text-shadow: 0 0 10px #ff44aa;
|
||
animation: iconPulse 1.8s ease-in-out infinite alternate;
|
||
flex-shrink: 0;
|
||
}
|
||
@keyframes iconPulse {
|
||
from { opacity: 0.5; transform: scale(0.9); }
|
||
to { opacity: 1; transform: scale(1.1); }
|
||
}
|
||
.music-scroll-wrap {
|
||
overflow: hidden;
|
||
max-width: 60vw;
|
||
}
|
||
.music-text {
|
||
display: inline-block;
|
||
color: #ffffff;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: clamp(11px, 1.2vw, 17px);
|
||
text-shadow: 0 0 8px rgba(0,200,255,0.7);
|
||
white-space: nowrap;
|
||
animation: musicScroll 18s linear infinite;
|
||
}
|
||
.music-text.short { animation: none; }
|
||
#musicArtist { color: rgba(0, 200, 255, 0.85); }
|
||
#musicTitle { color: #ffffff; }
|
||
.music-sep { color: rgba(255,255,255,0.4); }
|
||
|
||
@keyframes musicScroll {
|
||
0% { transform: translateX(100%); }
|
||
100% { transform: translateX(-100%); }
|
||
}
|
||
|
||
/* ============================================================
|
||
MUSIC NOTES (JS-spawned)
|
||
============================================================ */
|
||
.music-note {
|
||
position: absolute;
|
||
pointer-events: none;
|
||
animation: noteFly linear forwards;
|
||
opacity: 0;
|
||
}
|
||
@keyframes noteFly {
|
||
0% { transform: translateY(0) rotate(-10deg) scale(0.6); opacity: 0; }
|
||
12% { opacity: 1; }
|
||
88% { opacity: 0.9; }
|
||
100% { transform: translateY(-110px) rotate(18deg) scale(1.3); opacity: 0; }
|
||
}
|
||
|
||
/* ============================================================
|
||
LYRICS DISPLAY — verse karaoke
|
||
============================================================ */
|
||
.lyric-display {
|
||
position: absolute;
|
||
left: 2%;
|
||
top: 4%;
|
||
width: 38%;
|
||
height: 150px;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.5s ease;
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
|
||
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
|
||
}
|
||
.lyric-inner {
|
||
position: relative;
|
||
will-change: transform;
|
||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.lyric-ctx {
|
||
display: block;
|
||
width: 100%;
|
||
font-family: 'Courier New', monospace;
|
||
letter-spacing: 0.02em;
|
||
white-space: normal;
|
||
word-break: break-word;
|
||
line-height: 1.4;
|
||
padding: 4px 0;
|
||
transition:
|
||
opacity 0.45s ease,
|
||
font-size 0.35s ease,
|
||
color 0.35s ease,
|
||
text-shadow 0.35s ease;
|
||
}
|
||
|
||
/* ============================================================
|
||
SCANLINES OVERLAY
|
||
============================================================ */
|
||
.scanlines {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: repeating-linear-gradient(
|
||
0deg,
|
||
transparent 0px, transparent 3px,
|
||
rgba(0,0,0,0.07) 3px, rgba(0,0,0,0.07) 4px
|
||
);
|
||
pointer-events: none;
|
||
z-index: 9999;
|
||
}
|
||
|
||
/* ============================================================
|
||
ROBOT WRAPPER
|
||
120 BPM = 0.5s per beat. All animations key off this tempo.
|
||
============================================================ */
|
||
.robot-wrap {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 28%;
|
||
transform: translateX(-50%);
|
||
opacity: 0;
|
||
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: 44vh;
|
||
width: auto;
|
||
filter: drop-shadow(0 0 20px rgba(0,200,255,0.4));
|
||
}
|
||
|
||
/* Entire robot bounces on the beat (0.5s = 1 beat at 120 BPM) */
|
||
.robot {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
animation: robotBounce 0.5s ease-in-out infinite alternate;
|
||
}
|
||
@keyframes robotBounce {
|
||
from { transform: translateY(0) scaleY(1) scaleX(1); }
|
||
to { transform: translateY(-16px) scaleY(1.02) scaleX(0.99); }
|
||
}
|
||
|
||
/* ---- ANTENNA ---- */
|
||
.antenna-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-end;
|
||
height: 44px;
|
||
}
|
||
.antenna-stick {
|
||
width: 5px; height: 36px;
|
||
background: linear-gradient(to top, #6678a0, #aabbd0);
|
||
border-radius: 3px;
|
||
position: relative;
|
||
transform-origin: bottom center;
|
||
animation: antennaSway 0.5s ease-in-out infinite alternate;
|
||
}
|
||
/* The sway is opposite to the bounce so it looks reactive */
|
||
@keyframes antennaSway {
|
||
from { transform: rotate(-14deg); }
|
||
to { transform: rotate(14deg); }
|
||
}
|
||
.antenna-ball {
|
||
position: absolute;
|
||
top: -11px; left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 16px; height: 16px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 35% 25%, #ff99cc, #ff0077);
|
||
box-shadow: 0 0 12px #ff0077, 0 0 30px rgba(255,0,100,0.5);
|
||
animation: ballBlink 0.25s ease-in-out infinite alternate;
|
||
}
|
||
@keyframes ballBlink {
|
||
from { opacity: 0.45; box-shadow: 0 0 5px #ff0077; }
|
||
to { opacity: 1; box-shadow: 0 0 18px #ff0077, 0 0 40px rgba(255,0,100,0.6); }
|
||
}
|
||
|
||
/* ---- HEAD ---- */
|
||
.head {
|
||
width: 94px; height: 78px;
|
||
background: linear-gradient(145deg, #c8d4e8 0%, #8898b8 55%, #6070a0 100%);
|
||
border-radius: 22px;
|
||
border: 2px solid #99aacc;
|
||
box-shadow:
|
||
inset 0 3px 7px rgba(255,255,255,0.38),
|
||
inset 0 -2px 5px rgba(0,0,60,0.3),
|
||
0 6px 18px rgba(0,0,100,0.5);
|
||
position: relative;
|
||
animation: headTilt 1s ease-in-out infinite;
|
||
margin-top: 3px;
|
||
}
|
||
@keyframes headTilt {
|
||
0% { transform: rotate(-4deg) translateY(0); }
|
||
25% { transform: rotate(0deg) translateY(-3px); }
|
||
50% { transform: rotate(4deg) translateY(0); }
|
||
75% { transform: rotate(0deg) translateY(-3px); }
|
||
100% { transform: rotate(-4deg) translateY(0); }
|
||
}
|
||
|
||
/* EYES */
|
||
.eyes {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
padding: 20px 14px 0;
|
||
}
|
||
.eye {
|
||
width: 23px; height: 23px;
|
||
background: radial-gradient(circle at 38% 30%, #aaffdd, #00cc66);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 10px #00ff88, 0 0 22px rgba(0,255,136,0.45);
|
||
animation: eyeBlink 3.7s ease-in-out infinite;
|
||
position: relative;
|
||
}
|
||
/* Specular dot */
|
||
.eye::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 5px; left: 5px;
|
||
width: 7px; height: 7px;
|
||
background: rgba(255,255,255,0.75);
|
||
border-radius: 50%;
|
||
}
|
||
/* Second eye blinks slightly offset */
|
||
.eye:last-child {
|
||
animation-delay: 0.08s;
|
||
}
|
||
@keyframes eyeBlink {
|
||
0%, 87%, 100% { transform: scaleY(1); }
|
||
93% { transform: scaleY(0.07); }
|
||
}
|
||
|
||
/* LED MOUTH */
|
||
.mouth-panel {
|
||
margin: 7px auto 0;
|
||
width: 54px; height: 11px;
|
||
background: #000c20;
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(0,200,255,0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
padding: 0 5px;
|
||
overflow: hidden;
|
||
}
|
||
.mouth-seg {
|
||
width: 6px; height: 7px;
|
||
border-radius: 2px;
|
||
animation: mouthSegAnim 0.5s ease-in-out infinite alternate;
|
||
}
|
||
.mouth-seg:nth-child(1) { background:#00ddff; animation-delay:0.00s; }
|
||
.mouth-seg:nth-child(2) { background:#00ccee; animation-delay:0.10s; }
|
||
.mouth-seg:nth-child(3) { background:#00bbff; animation-delay:0.05s; }
|
||
.mouth-seg:nth-child(4) { background:#00ccee; animation-delay:0.10s; }
|
||
.mouth-seg:nth-child(5) { background:#00ddff; animation-delay:0.00s; }
|
||
@keyframes mouthSegAnim {
|
||
from { opacity:1; box-shadow: 0 0 4px currentColor; }
|
||
to { opacity:0.25; box-shadow: none; }
|
||
}
|
||
|
||
/* ---- NECK ---- */
|
||
.neck {
|
||
width: 26px; height: 13px;
|
||
background: linear-gradient(to bottom, #7080a8, #50607a);
|
||
border-radius: 0 0 5px 5px;
|
||
box-shadow: inset 0 -2px 4px rgba(0,0,60,0.4);
|
||
}
|
||
|
||
/* ---- MIDDLE: left arm + torso + right arm ---- */
|
||
.middle {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* SHOULDER BALL */
|
||
.shoulder {
|
||
width: 18px; height: 18px;
|
||
margin-top: 10px;
|
||
background: radial-gradient(circle at 32% 28%, #aabbd0, #5a6890);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 8px rgba(0,100,255,0.25);
|
||
z-index: 1;
|
||
}
|
||
|
||
/* UPPER ARM */
|
||
.arm {
|
||
width: 21px; height: 66px;
|
||
background: linear-gradient(to bottom, #8090b8, #5868a0);
|
||
border-radius: 11px;
|
||
position: relative;
|
||
transform-origin: top center;
|
||
box-shadow:
|
||
inset 0 2px 4px rgba(255,255,255,0.2),
|
||
0 3px 10px rgba(0,0,80,0.4);
|
||
margin-top: 4px;
|
||
}
|
||
.arm.left { animation: armSwingL 1s ease-in-out infinite; }
|
||
.arm.right { animation: armSwingR 1s ease-in-out infinite; }
|
||
|
||
@keyframes armSwingL {
|
||
0%, 100% { transform: rotate(-52deg); }
|
||
50% { transform: rotate(42deg); }
|
||
}
|
||
@keyframes armSwingR {
|
||
0%, 100% { transform: rotate(52deg); }
|
||
50% { transform: rotate(-42deg); }
|
||
}
|
||
|
||
/* HAND */
|
||
.hand {
|
||
position: absolute;
|
||
bottom: -13px; left: -7px;
|
||
width: 35px; height: 35px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 35% 30%, #aabbd0, #5868a0);
|
||
box-shadow: 0 0 10px rgba(0,100,255,0.22);
|
||
}
|
||
|
||
/* ---- TORSO ---- */
|
||
.torso {
|
||
width: 104px; height: 95px;
|
||
background: linear-gradient(145deg, #9aaac0 0%, #6070a0 55%, #485888 100%);
|
||
border-radius: 14px;
|
||
border: 2px solid #7888b8;
|
||
box-shadow:
|
||
inset 0 3px 7px rgba(255,255,255,0.2),
|
||
inset 0 -3px 7px rgba(0,0,60,0.3),
|
||
0 6px 22px rgba(0,0,100,0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
}
|
||
|
||
/* CHEST SCREEN */
|
||
.chest-screen {
|
||
width: 72px; height: 60px;
|
||
background: #000c20;
|
||
border-radius: 9px;
|
||
border: 1px solid rgba(0,200,255,0.65);
|
||
box-shadow: 0 0 14px rgba(0,200,255,0.5), inset 0 0 14px rgba(0,80,200,0.3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: space-evenly;
|
||
padding: 6px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
/* Glass sheen */
|
||
.chest-screen::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0; right: 50%; height: 50%;
|
||
background: linear-gradient(135deg, rgba(255,255,255,0.06), transparent);
|
||
border-radius: 9px 0 0 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.eq-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 3px;
|
||
width: 100%; height: 13px;
|
||
}
|
||
.eq-col {
|
||
flex: 1;
|
||
border-radius: 2px 2px 0 0;
|
||
/* height and animation driven by JS */
|
||
}
|
||
|
||
.chest-logo {
|
||
color: #00ccff;
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
text-shadow: 0 0 6px #00ccff;
|
||
letter-spacing: 2px;
|
||
animation: logoFlicker 1.5s steps(2) infinite;
|
||
}
|
||
@keyframes logoFlicker {
|
||
0%, 66% { opacity: 1; }
|
||
33%, 100% { opacity: 0.55; }
|
||
}
|
||
|
||
/* ---- HIP ---- */
|
||
.hip {
|
||
width: 92px; height: 24px;
|
||
background: linear-gradient(to bottom, #5868a0, #404a78);
|
||
border-radius: 0 0 10px 10px;
|
||
border: 1px solid rgba(80,100,180,0.6);
|
||
box-shadow: 0 5px 12px rgba(0,0,100,0.5);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.rivet {
|
||
width: 9px; height: 9px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 32% 28%, #aabbd0, #4a5880);
|
||
box-shadow: 0 1px 3px rgba(0,0,80,0.5);
|
||
}
|
||
|
||
/* ---- LEGS ---- */
|
||
.legs {
|
||
display: flex;
|
||
gap: 14px;
|
||
margin-top: 2px;
|
||
}
|
||
.leg-grp {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
/* UPPER LEG */
|
||
.upper-leg {
|
||
width: 28px; height: 68px;
|
||
background: linear-gradient(to bottom, #6070a0, #485878);
|
||
border-radius: 14px;
|
||
transform-origin: top center;
|
||
box-shadow:
|
||
inset 0 2px 5px rgba(255,255,255,0.15),
|
||
0 3px 10px rgba(0,0,80,0.5);
|
||
}
|
||
.upper-leg.left { animation: upperLegL 1s ease-in-out infinite; }
|
||
.upper-leg.right { animation: upperLegR 1s ease-in-out infinite; }
|
||
|
||
@keyframes upperLegL {
|
||
0% { transform: rotate(-18deg); }
|
||
25% { transform: rotate(4deg) translateY(-4px); }
|
||
50% { transform: rotate(18deg); }
|
||
75% { transform: rotate(4deg) translateY(-4px); }
|
||
100% { transform: rotate(-18deg); }
|
||
}
|
||
@keyframes upperLegR {
|
||
0% { transform: rotate(18deg); }
|
||
25% { transform: rotate(4deg) translateY(-4px); }
|
||
50% { transform: rotate(-18deg); }
|
||
75% { transform: rotate(4deg) translateY(-4px); }
|
||
100% { transform: rotate(18deg); }
|
||
}
|
||
|
||
/* KNEE JOINT */
|
||
.knee {
|
||
width: 22px; height: 22px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 35% 28%, #8090b8, #384868);
|
||
box-shadow: 0 2px 7px rgba(0,0,100,0.5);
|
||
margin: -3px 0;
|
||
}
|
||
|
||
/* LOWER LEG */
|
||
.lower-leg {
|
||
width: 23px; height: 52px;
|
||
background: linear-gradient(to bottom, #505878, #303858);
|
||
border-radius: 12px;
|
||
position: relative;
|
||
transform-origin: top center;
|
||
box-shadow:
|
||
inset 0 2px 4px rgba(255,255,255,0.12),
|
||
0 3px 10px rgba(0,0,80,0.5);
|
||
}
|
||
.lower-leg.left { animation: lowerLegL 1s ease-in-out infinite; }
|
||
.lower-leg.right { animation: lowerLegR 1s ease-in-out infinite; }
|
||
|
||
/* Lower legs bend opposite to upper, giving a knee-flex effect */
|
||
@keyframes lowerLegL {
|
||
0%, 100% { transform: rotate(12deg); }
|
||
50% { transform: rotate(-18deg); }
|
||
}
|
||
@keyframes lowerLegR {
|
||
0%, 100% { transform: rotate(-12deg); }
|
||
50% { transform: rotate(18deg); }
|
||
}
|
||
|
||
/* FOOT */
|
||
.foot {
|
||
position: absolute;
|
||
bottom: -15px; left: -9px;
|
||
width: 41px; height: 21px;
|
||
background: linear-gradient(135deg, #6878a8, #384068);
|
||
border-radius: 8px 14px 16px 6px;
|
||
box-shadow: 0 7px 10px rgba(0,0,100,0.6), 0 0 12px rgba(0,80,255,0.12);
|
||
}
|
||
/* Toe ridge */
|
||
.foot::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 3px; right: 5px;
|
||
width: 55%; height: 4px;
|
||
background: rgba(100,130,210,0.35);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ============================================================
|
||
DANCE STYLE VARIANTS (JS switches body data-dance attr)
|
||
============================================================ */
|
||
|
||
/* SPIN mode — whole robot rotates */
|
||
[data-dance="spin"] .robot {
|
||
animation: robotSpin 2s linear infinite !important;
|
||
}
|
||
@keyframes robotSpin {
|
||
from { transform: rotateY(0deg) translateY(-8px); }
|
||
to { transform: rotateY(360deg) translateY(-8px); }
|
||
}
|
||
|
||
/* WAVE mode — arms raise high repeatedly */
|
||
[data-dance="wave"] .arm.left {
|
||
animation: armWaveL 0.5s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="wave"] .arm.right {
|
||
animation: armWaveR 0.5s ease-in-out infinite !important;
|
||
}
|
||
@keyframes armWaveL {
|
||
0%, 100% { transform: rotate(-80deg); }
|
||
50% { transform: rotate(-10deg); }
|
||
}
|
||
@keyframes armWaveR {
|
||
0%, 100% { transform: rotate(80deg); }
|
||
50% { transform: rotate(10deg); }
|
||
}
|
||
[data-dance="wave"] .robot {
|
||
animation: robotBounce 0.25s ease-in-out infinite alternate !important;
|
||
}
|
||
|
||
/* SHUFFLE mode — fast leg steps */
|
||
[data-dance="shuffle"] .upper-leg.left {
|
||
animation: upperLegL 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .upper-leg.right {
|
||
animation: upperLegR 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .lower-leg.left {
|
||
animation: lowerLegL 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .lower-leg.right {
|
||
animation: lowerLegR 0.25s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .arm.left {
|
||
animation: armSwingL 0.5s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="shuffle"] .arm.right {
|
||
animation: armSwingR 0.5s ease-in-out infinite !important;
|
||
}
|
||
|
||
/* MOONWALK mode — body slides and leans */
|
||
[data-dance="moonwalk"] .robot {
|
||
animation: moonwalkLean 1s ease-in-out infinite alternate !important;
|
||
}
|
||
@keyframes moonwalkLean {
|
||
from { transform: translateX(-20px) rotate(-5deg); }
|
||
to { transform: translateX(20px) rotate(5deg); }
|
||
}
|
||
[data-dance="moonwalk"] .upper-leg.left {
|
||
animation: moonLegL 1s ease-in-out infinite !important;
|
||
}
|
||
[data-dance="moonwalk"] .upper-leg.right {
|
||
animation: moonLegR 1s ease-in-out infinite !important;
|
||
}
|
||
@keyframes moonLegL {
|
||
0%, 100% { transform: rotate(25deg); }
|
||
50% { transform: rotate(-5deg); }
|
||
}
|
||
@keyframes moonLegR {
|
||
0%, 100% { transform: rotate(-25deg); }
|
||
50% { transform: rotate(5deg); }
|
||
}
|
||
|
||
/* ============================================================
|
||
SYNTHWAVE SUN
|
||
============================================================ */
|
||
.synthwave-sun {
|
||
position: absolute;
|
||
width: 280px;
|
||
height: 280px;
|
||
border-radius: 50%;
|
||
left: 50%;
|
||
bottom: 41.5%;
|
||
transform: translateX(-50%) translateY(50%);
|
||
transition:
|
||
bottom 2.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||
transform 2.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||
background 7s ease,
|
||
box-shadow 7s ease,
|
||
width 3s ease,
|
||
height 3s ease;
|
||
/* listras geradas por JS — ver buildSunStripes() */
|
||
background:
|
||
linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.28)),
|
||
linear-gradient(to bottom,
|
||
#fff9cc 0%,
|
||
#ffcc00 20%,
|
||
#ff7700 45%,
|
||
#ff1166 70%,
|
||
#aa00ee 100%
|
||
);
|
||
box-shadow:
|
||
0 0 50px rgba(255,120, 0, 0.7),
|
||
0 0 120px rgba(255, 20,100, 0.45),
|
||
0 0 220px rgba(150, 0,255, 0.25);
|
||
}
|
||
/* atmospheric halo above the sun */
|
||
.synthwave-sun::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -35%;
|
||
border-radius: 50%;
|
||
background: radial-gradient(ellipse at 50% 60%,
|
||
rgba(255,100, 0, 0.18) 0%,
|
||
rgba(255, 0,120, 0.12) 40%,
|
||
transparent 70%);
|
||
filter: blur(18px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ============================================================
|
||
FOG — synthwave atmospheric haze rising above horizon
|
||
============================================================ */
|
||
.fog-layer {
|
||
position: absolute;
|
||
bottom: 41.5%;
|
||
left: 0; right: 0;
|
||
height: 38%;
|
||
pointer-events: none;
|
||
overflow: visible;
|
||
}
|
||
.fog-wisp {
|
||
position: absolute;
|
||
bottom: -10px;
|
||
border-radius: 50%;
|
||
filter: blur(45px);
|
||
will-change: transform, opacity;
|
||
}
|
||
.fog-w1 { width: 75%; height: 140px; left: -8%; animation: fogDrift1 9s ease-in-out infinite alternate; }
|
||
.fog-w2 { width: 60%; height: 120px; left: 25%; animation: fogDrift2 13s ease-in-out infinite alternate; animation-delay: -4s; }
|
||
.fog-w3 { width: 55%; height: 110px; left: 48%; animation: fogDrift3 8s ease-in-out infinite alternate; animation-delay: -2s; }
|
||
|
||
@keyframes fogDrift1 {
|
||
from { transform: translateY(0) scaleX(1); opacity: 0.32; }
|
||
to { transform: translateY(-50px) scaleX(1.18); opacity: 0.55; }
|
||
}
|
||
@keyframes fogDrift2 {
|
||
from { transform: translateY(-12px) scaleX(0.93); opacity: 0.38; }
|
||
to { transform: translateY(-60px) scaleX(1.22); opacity: 0.50; }
|
||
}
|
||
@keyframes fogDrift3 {
|
||
from { transform: translateY(-6px) scaleX(1.06); opacity: 0.28; }
|
||
to { transform: translateY(-38px) scaleX(0.88); opacity: 0.45; }
|
||
}
|
||
|
||
/* ============================================================
|
||
BALADA — SOL VIRA DISCO (raios giram ao redor do centro)
|
||
============================================================ */
|
||
|
||
/* Sol sobe para o céu */
|
||
body.balada .synthwave-sun {
|
||
bottom: 70%;
|
||
transform: translateX(-50%) translateY(0);
|
||
/* background fica no #sunBaladaBg para crossfade suave via opacidade */
|
||
box-shadow:
|
||
0 0 10px 2px rgba(255,113,206,0.18),
|
||
0 0 28px 6px rgba(185,103,255,0.10),
|
||
0 0 60px 12px rgba(1,205,254,0.05),
|
||
0 0 120px 25px rgba(185,103,255,0.03);
|
||
}
|
||
|
||
|
||
/* Overlay de balada — fade in/out suave sobre o fundo retrowave */
|
||
#sunBaladaBg {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 9s ease;
|
||
background: radial-gradient(circle at 40% 35%, #2a0050 0%, #08000f 100%);
|
||
}
|
||
body.balada #sunBaladaBg { opacity: 1; }
|
||
|
||
/* Listras orgânicas do sol retrowave — geradas por buildSunStripes() */
|
||
#sunStripes {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
opacity: 1;
|
||
transition: opacity 9s ease;
|
||
}
|
||
body.balada #sunStripes { opacity: 0; }
|
||
|
||
/* Linhas do globo desenhadas via canvas JS — ver drawGlobe() */
|
||
body.balada .synthwave-sun::before { display: none; }
|
||
|
||
/* Halo vaporwave — mais discreto */
|
||
body.balada .synthwave-sun::after {
|
||
inset: -50%;
|
||
background: radial-gradient(ellipse at 50% 50%,
|
||
rgba(255,113,206,0.12) 0%,
|
||
rgba(185,103,255,0.08) 38%,
|
||
rgba(1,205,254,0.04) 60%,
|
||
transparent 74%
|
||
);
|
||
animation: haloBreath 4s ease-in-out infinite alternate;
|
||
filter: blur(22px);
|
||
}
|
||
@keyframes haloBreath {
|
||
from { transform: scale(0.97); opacity: 0.5; }
|
||
to { transform: scale(1.06); opacity: 0.85; }
|
||
}
|
||
|
||
/* Raios: container centrado no sol (140px = raio de 280px) */
|
||
.sun-rays-wrap {
|
||
position: absolute;
|
||
top: 140px; left: 140px;
|
||
width: 0; height: 0;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 1.8s ease 1s;
|
||
}
|
||
body.balada .sun-rays-wrap { opacity: 1; }
|
||
|
||
.sun-ray {
|
||
position: absolute;
|
||
top: -1.5px; left: 0;
|
||
height: 3px;
|
||
transform-origin: left center;
|
||
border-radius: 0 3px 3px 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
BALADA — REFLETORES (SPOTLIGHTS)
|
||
============================================================ */
|
||
.spotlight-wrap {
|
||
position: absolute; inset: 0;
|
||
pointer-events: none;
|
||
z-index: 41;
|
||
opacity: 0;
|
||
transition: opacity 2.2s ease 0.6s;
|
||
}
|
||
body.balada .spotlight-wrap { opacity: 1; }
|
||
|
||
.spl {
|
||
position: absolute;
|
||
top: 0;
|
||
}
|
||
.spl::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -60px; /* começa acima da tela — esconde o corte da geometria */
|
||
left: -35px;
|
||
width: 70px; height: calc(68vh + 60px);
|
||
clip-path: polygon(22% 0%, 78% 0%, 100% 100%, 0% 100%);
|
||
transform-origin: 50% 60px; /* pivô na borda superior da tela */
|
||
opacity: 0.12;
|
||
}
|
||
.spl-1 { left: 15%; }
|
||
.spl-1::after { background: linear-gradient(rgba(255,113,206,0.9),transparent); animation: sws1 9s ease-in-out infinite; }
|
||
.spl-2 { left: 50%; }
|
||
.spl-2::after { background: linear-gradient(rgba(1,205,254,0.9),transparent); animation: sws2 11s ease-in-out infinite; animation-delay:-3s; }
|
||
.spl-3 { left: 85%; }
|
||
.spl-3::after { background: linear-gradient(rgba(185,103,255,0.9),transparent); animation: sws1 8s ease-in-out infinite; animation-delay:-2s; }
|
||
.spl-4 { left: 30%; }
|
||
.spl-4::after { background: linear-gradient(rgba(5,255,161,0.9),transparent); animation: sws3 13s ease-in-out infinite; animation-delay:-5s; }
|
||
.spl-5 { left: 70%; }
|
||
.spl-5::after { background: linear-gradient(rgba(255,251,150,0.9),transparent); animation: sws2 10s ease-in-out infinite; animation-delay:-7s; }
|
||
|
||
@keyframes sws1 { 0%,100%{ transform:rotate(-20deg); } 50%{ transform:rotate(20deg); } }
|
||
@keyframes sws2 { 0%,100%{ transform:rotate(24deg); } 50%{ transform:rotate(-24deg); } }
|
||
@keyframes sws3 { 0%,100%{ transform:rotate(-14deg); } 50%{ transform:rotate(30deg); } }
|
||
|
||
/* ============================================================
|
||
BALADA — PARTÍCULAS DE LUZ + STROBE
|
||
============================================================ */
|
||
.disco-dot {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
animation: discoDotFly linear forwards;
|
||
}
|
||
@keyframes discoDotFly {
|
||
0% { opacity:0; transform:translate(0,0) scale(0); }
|
||
8% { opacity:1; transform:translate(var(--tx1),var(--ty1)) scale(1); }
|
||
90% { opacity:0.85; transform:translate(var(--tx2),var(--ty2)) scale(0.6); }
|
||
100%{ opacity:0; transform:translate(var(--tx3),var(--ty3)) scale(0); }
|
||
}
|
||
.strobe {
|
||
position: fixed; inset: 0;
|
||
background: white; opacity: 0;
|
||
pointer-events: none; z-index: 9990;
|
||
mix-blend-mode: overlay;
|
||
}
|
||
|
||
/* ============================================================
|
||
BALADA — SOBRESCRITAS DO AMBIENTE
|
||
============================================================ */
|
||
.nebula-a::after { background: radial-gradient(ellipse, rgba(255,0,100,0.22), transparent 70%); }
|
||
.nebula-b::after { background: radial-gradient(ellipse, rgba(180,0,255,0.18), transparent 70%); }
|
||
.nebula-c::after { background: radial-gradient(ellipse, rgba(255,140,0,0.14), transparent 70%); }
|
||
|
||
/* ============================================================
|
||
CHUVA — CANVAS DE CHUVA
|
||
============================================================ */
|
||
#rainCanvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
z-index: 45;
|
||
mix-blend-mode: screen;
|
||
}
|
||
|
||
/* ============================================================
|
||
CHUVA — MODO CHUVA
|
||
============================================================ */
|
||
|
||
/* Fundo: céu tempestuoso escuro */
|
||
body.chuva { background: #04080f; }
|
||
|
||
/* Estrelas somem atrás das nuvens */
|
||
#starfield { transition: opacity 3s ease; }
|
||
body.chuva #starfield { opacity: 0.1; }
|
||
|
||
/* Meteoros somem — a chuva substitui */
|
||
#meteorLayer { transition: opacity 2s ease; }
|
||
body.chuva #meteorLayer { opacity: 0; }
|
||
|
||
/* Sol vira lua: sobe, encolhe, fica prateado */
|
||
body.chuva .synthwave-sun {
|
||
bottom: 70%;
|
||
transform: translateX(-50%) translateY(0);
|
||
width: 160px;
|
||
height: 160px;
|
||
box-shadow:
|
||
0 0 22px rgba(180,205,255,0.35),
|
||
0 0 70px rgba(150,180,255,0.12),
|
||
0 0 160px rgba(120,155,255,0.05);
|
||
}
|
||
|
||
/* Halo lunar — suave e frio */
|
||
body.chuva .synthwave-sun::after {
|
||
inset: -45%;
|
||
background: radial-gradient(ellipse at 50% 50%,
|
||
rgba(190,210,255,0.12) 0%,
|
||
rgba(160,185,255,0.07) 40%,
|
||
transparent 65%
|
||
);
|
||
animation: none;
|
||
filter: blur(18px);
|
||
}
|
||
|
||
/* Listras do sol somem, elementos de balada somem */
|
||
body.chuva #sunStripes { opacity: 0; }
|
||
body.chuva #sunBaladaBg { opacity: 0; transition: opacity 1.5s ease; }
|
||
body.chuva .sun-rays-wrap { opacity: 0; }
|
||
body.chuva .spotlight-wrap { opacity: 0; }
|
||
body.chuva #discoDots { opacity: 0; }
|
||
|
||
/* Nuvens de tempestade via nebulae */
|
||
body.chuva .nebula::after { opacity: 1; }
|
||
body.chuva .nebula-a::after { background: radial-gradient(ellipse, rgba(35,45,75,0.55), transparent 70%); }
|
||
body.chuva .nebula-b::after { background: radial-gradient(ellipse, rgba(25,35,65,0.48), transparent 70%); }
|
||
body.chuva .nebula-c::after { background: radial-gradient(ellipse, rgba(45,55,85,0.42), transparent 70%); }
|
||
|
||
/* Horizonte frio e tempestuoso */
|
||
body.chuva .horizon {
|
||
background: linear-gradient(
|
||
to right,
|
||
transparent 0%,
|
||
rgba(70,120,200,0.5) 20%,
|
||
rgba(120,165,245,0.75) 50%,
|
||
rgba(70,120,200,0.5) 80%,
|
||
transparent 100%
|
||
) !important;
|
||
box-shadow:
|
||
0 0 18px rgba(100,150,235,0.55),
|
||
0 0 65px rgba(80,125,210,0.18) !important;
|
||
}
|
||
|
||
/* Face lunar */
|
||
#sunChuvaFace {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 1.2s ease; /* saída rápida — entrada lenta definida em body.chuva */
|
||
background: radial-gradient(circle at 38% 32%,
|
||
#eef2fa 0%,
|
||
#c0cad8 42%,
|
||
#7888a8 78%,
|
||
#506080 100%
|
||
);
|
||
}
|
||
#sunChuvaFace::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 66% 27%,
|
||
rgba(18,28,58,0.72) 22%,
|
||
rgba(22,38,72,0.38) 52%,
|
||
transparent 70%
|
||
);
|
||
}
|
||
body.chuva #sunChuvaFace { opacity: 1; transition: opacity 5s ease; }
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="scene">
|
||
|
||
<!-- Nebula blobs -->
|
||
<div class="nebula nebula-a"></div>
|
||
<div class="nebula nebula-b"></div>
|
||
<div class="nebula nebula-c"></div>
|
||
|
||
<!-- Twinkling starfield -->
|
||
<canvas id="starfield"></canvas>
|
||
|
||
<!-- Synthwave sun — sits behind floor grid and horizon; becomes disco ball in balada mode -->
|
||
<div class="synthwave-sun">
|
||
<div id="sunBaladaBg"></div>
|
||
<div id="sunStripes"></div>
|
||
<div id="sunChuvaFace"></div>
|
||
<div class="sun-rays-wrap" id="sunRaysWrap"></div>
|
||
</div>
|
||
|
||
<!-- Retrowave grid floor -->
|
||
<canvas id="floorCanvas"></canvas>
|
||
|
||
<!-- Horizon glow -->
|
||
<div class="horizon"></div>
|
||
|
||
<!-- Dynamic fog above horizon -->
|
||
<div class="fog-layer" id="fogLayer">
|
||
<div class="fog-wisp fog-w1"></div>
|
||
<div class="fog-wisp fog-w2"></div>
|
||
<div class="fog-wisp fog-w3"></div>
|
||
</div>
|
||
|
||
<!-- Meteor layer (masked at horizon) -->
|
||
<div id="meteorLayer"></div>
|
||
|
||
<!-- ===================== ROBOT ===================== -->
|
||
<div class="robot-wrap">
|
||
<div class="beat-wrap">
|
||
<img src="skeleton3.apng" class="skeleton-gif" alt="skeleton">
|
||
</div>
|
||
</div><!-- .robot-wrap -->
|
||
|
||
<!-- VU Meter -->
|
||
<div class="vu-meter" id="vuMeter"></div>
|
||
|
||
<!-- Album art -->
|
||
<div id="albumArt" class="album-art" style="opacity:0"><img id="albumArtImg" src="" alt=""></div>
|
||
|
||
<!-- Music ticker -->
|
||
<div class="music-ticker">
|
||
<span class="music-icon">♫</span>
|
||
<div class="music-scroll-wrap">
|
||
<span class="music-text short" id="musicText">
|
||
<span id="musicArtist"></span><span class="music-sep" id="musicSep"></span><span id="musicTitle"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lyrics display — verse karaoke -->
|
||
<div class="lyric-display" id="lyricDisplay"><div class="lyric-inner" id="lyricInner"></div></div>
|
||
|
||
<!-- BALADA: refletores do teto -->
|
||
<div class="spotlight-wrap">
|
||
<div class="spl spl-1"></div>
|
||
<div class="spl spl-2"></div>
|
||
<div class="spl spl-3"></div>
|
||
<div class="spl spl-4"></div>
|
||
<div class="spl spl-5"></div>
|
||
</div>
|
||
|
||
<!-- BALADA: partículas de luz -->
|
||
<div id="discoDots" style="position:absolute;inset:0;pointer-events:none;z-index:48;"></div>
|
||
|
||
<!-- CHUVA: canvas de chuva -->
|
||
<canvas id="rainCanvas"></canvas>
|
||
|
||
<!-- Scanlines overlay -->
|
||
<div class="scanlines"></div>
|
||
|
||
<!-- Strobe flash -->
|
||
<div class="strobe" id="strobe"></div>
|
||
|
||
</div><!-- .scene -->
|
||
|
||
<script>
|
||
/* ============================================================
|
||
CONSTANTS
|
||
120 BPM → beat = 500ms. All timings derived from this.
|
||
============================================================ */
|
||
const BPM = 120;
|
||
const BEAT_MS = 60000 / BPM; // 500ms
|
||
|
||
/* ============================================================
|
||
STARFIELD
|
||
============================================================ */
|
||
const canvas = document.getElementById('starfield');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const STAR_COUNT = 220;
|
||
const stars = [];
|
||
|
||
const STAR_COLORS = [[255,221,170],[204,216,255],[238,238,255]];
|
||
|
||
function initStars() {
|
||
stars.length = 0;
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
const c = STAR_COLORS[Math.random() < 0.15 ? 0 : Math.random() < 0.5 ? 1 : 2];
|
||
stars.push({
|
||
x: Math.random() * canvas.width,
|
||
y: Math.random() * canvas.height * 0.62,
|
||
r: Math.random() * 1.6 + 0.2,
|
||
phase: Math.random() * Math.PI * 2,
|
||
freq: 0.4 + Math.random() * 1.2,
|
||
cr: c[0], cg: c[1], cb: c[2],
|
||
});
|
||
}
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
initStars();
|
||
}
|
||
|
||
function drawStars(ts) {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const t = ts * 0.001;
|
||
stars.forEach(s => {
|
||
const alpha = (0.25 + 0.75 * (0.5 + 0.5 * Math.sin(t * s.freq + s.phase))).toFixed(2);
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(${s.cr},${s.cg},${s.cb},${alpha})`;
|
||
ctx.fill();
|
||
});
|
||
requestAnimationFrame(drawStars);
|
||
}
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
|
||
/* ============================================================
|
||
FLOOR CANVAS — perspective grid with true vanishing point
|
||
============================================================ */
|
||
const floorCanvas = document.getElementById('floorCanvas');
|
||
const floorCtx = floorCanvas.getContext('2d');
|
||
|
||
const FLOOR_RATIO = 0.42;
|
||
|
||
/* Disco floor tiles */
|
||
const DISCO_COLORS = [
|
||
[255,113,206],[185,103,255],[1,205,254],
|
||
[5,255,161],[255,251,150],[255,68,170],
|
||
[200,0,255],[0,180,255]
|
||
];
|
||
const TCOLS = 24, TROWS = 24; // match V_LINES=24, H_LINES=24 for 1:1 tile alignment
|
||
const discoTileGrid = Array.from({length: TROWS * TCOLS}, () => {
|
||
const c = DISCO_COLORS[Math.floor(Math.random() * DISCO_COLORS.length)];
|
||
return { r: c[0], g: c[1], b: c[2], tr: c[0], tg: c[1], tb: c[2], alpha: 0, target: 0,
|
||
rgba(a) { return `rgba(${this.r|0},${this.g|0},${this.b|0},${a.toFixed(2)})`; } };
|
||
});
|
||
let lastTileTs = 0;
|
||
function tickDiscoTiles(ts) {
|
||
if (ts - lastTileTs < 400) return;
|
||
lastTileTs = ts;
|
||
discoTileGrid.forEach(t => {
|
||
if (Math.random() < 0.04) {
|
||
const c = DISCO_COLORS[Math.floor(Math.random() * DISCO_COLORS.length)];
|
||
t.tr = c[0]; t.tg = c[1]; t.tb = c[2];
|
||
t.target = Math.random() < 0.4 ? 0 : 0.12 + Math.random() * 0.28;
|
||
}
|
||
});
|
||
}
|
||
const V_LINES = 24;
|
||
const H_LINES = 24;
|
||
let floorScroll = 0;
|
||
let lastFloorTs = 0;
|
||
let baladaAmt = 0; // 0 = retrowave, 1 = balada — transição suave
|
||
let floorFadeGrad = null;
|
||
let floorFadeGradH = 0;
|
||
|
||
function resizeFloor() {
|
||
floorCanvas.width = window.innerWidth;
|
||
floorCanvas.height = Math.round(window.innerHeight * FLOOR_RATIO);
|
||
}
|
||
|
||
function drawFloor(ts) {
|
||
const dt = lastFloorTs ? Math.min((ts - lastFloorTs) / 1000, 0.1) : 0;
|
||
lastFloorTs = ts;
|
||
floorScroll = (floorScroll + 0.06 * dt) % 1;
|
||
baladaAmt += ((balada ? 1 : 0) - baladaAmt) * Math.min(1, dt * 0.6); // ~1.7s de transição
|
||
|
||
const W = floorCanvas.width;
|
||
const H = floorCanvas.height;
|
||
const vx = W / 2;
|
||
|
||
floorCtx.clearRect(0, 0, W, H);
|
||
|
||
// Vertical lines radiating from vanishing point (center top)
|
||
const spread = W * 4;
|
||
const spreadStart = vx - spread / 2;
|
||
const floorHue = (ts * 0.04) % 360;
|
||
|
||
/* ── Tiles de balada anos 80 — fade controlado por baladaAmt ── */
|
||
if (baladaAmt > 0.01) {
|
||
tickDiscoTiles(ts);
|
||
const lerpA = Math.min(1, dt * 1.8);
|
||
const lerpC = Math.min(1, dt * 0.9);
|
||
discoTileGrid.forEach(t => {
|
||
t.alpha += (t.target - t.alpha) * lerpA;
|
||
t.r += (t.tr - t.r) * lerpC;
|
||
t.g += (t.tg - t.g) * lerpC;
|
||
t.b += (t.tb - t.b) * lerpC;
|
||
});
|
||
const colStep = spread / TCOLS;
|
||
for (let r = 0; r < TROWS; r++) {
|
||
const t1 = ((r / TROWS) + floorScroll) % 1;
|
||
const t2 = (((r + 1) / TROWS) + floorScroll) % 1;
|
||
const d1 = Math.pow(t1, 2.2), d2 = Math.pow(t2, 2.2);
|
||
const y1 = d1 * H, y2 = d2 * H;
|
||
if (y2 <= y1 || y2 - y1 < 1) continue;
|
||
for (let c = 0; c < TCOLS; c++) {
|
||
const tile = discoTileGrid[r * TCOLS + c];
|
||
if (tile.alpha < 0.01) continue;
|
||
const bx1 = spreadStart + c * colStep;
|
||
const bx2 = bx1 + colStep;
|
||
floorCtx.fillStyle = tile.rgba(tile.alpha * baladaAmt);
|
||
floorCtx.beginPath();
|
||
floorCtx.moveTo(vx + (bx1 - vx) * d1, y1);
|
||
floorCtx.lineTo(vx + (bx2 - vx) * d1, y1);
|
||
floorCtx.lineTo(vx + (bx2 - vx) * d2, y2);
|
||
floorCtx.lineTo(vx + (bx1 - vx) * d2, y2);
|
||
floorCtx.closePath();
|
||
floorCtx.fill();
|
||
}
|
||
}
|
||
}
|
||
|
||
const vH = 200 + (floorHue - 200) * baladaAmt;
|
||
floorCtx.strokeStyle = `hsla(${vH}, 100%, 65%, 0.32)`;
|
||
floorCtx.lineWidth = 2;
|
||
for (let i = 0; i <= V_LINES; i++) {
|
||
const bx = spreadStart + (i / V_LINES) * spread;
|
||
floorCtx.beginPath();
|
||
floorCtx.moveTo(vx, 0);
|
||
floorCtx.lineTo(bx, H);
|
||
floorCtx.stroke();
|
||
}
|
||
|
||
// Horizontal lines — perspective-correct spacing, animated
|
||
for (let i = 0; i < H_LINES; i++) {
|
||
const t = ((i / H_LINES) + floorScroll) % 1;
|
||
const depth = Math.pow(t, 2.2);
|
||
const y = depth * H;
|
||
const x1 = vx - (spread / 2) * depth;
|
||
const x2 = vx + (spread / 2) * depth;
|
||
const alpha = (0.12 + depth * 0.25).toFixed(2);
|
||
const hH = (200 + (floorHue + i * 12 - 200) * baladaAmt + 360) % 360;
|
||
floorCtx.strokeStyle = `hsla(${hH}, 100%, 65%, ${alpha})`;
|
||
floorCtx.lineWidth = Math.max(1, depth * 11);
|
||
floorCtx.beginPath();
|
||
floorCtx.moveTo(x1, y);
|
||
floorCtx.lineTo(x2, y);
|
||
floorCtx.stroke();
|
||
}
|
||
|
||
// Fade near horizon to hide sub-pixel lines (gradient cached, only rebuilt on resize)
|
||
if (!floorFadeGrad || floorFadeGradH !== H) {
|
||
floorFadeGradH = H;
|
||
floorFadeGrad = floorCtx.createLinearGradient(0, 0, 0, H * 0.3);
|
||
floorFadeGrad.addColorStop(0, '#000814');
|
||
floorFadeGrad.addColorStop(1, 'rgba(0,8,20,0)');
|
||
}
|
||
floorCtx.fillStyle = floorFadeGrad;
|
||
floorCtx.fillRect(0, 0, W, H * 0.3);
|
||
|
||
requestAnimationFrame(drawFloor);
|
||
}
|
||
|
||
window.addEventListener('resize', resizeFloor);
|
||
|
||
// WebEngine sometimes reports innerWidth=0 on first load — retry until valid.
|
||
function initCanvases() {
|
||
if (window.innerWidth > 0 && window.innerHeight > 0) {
|
||
resizeCanvas();
|
||
resizeFloor();
|
||
requestAnimationFrame(drawStars);
|
||
requestAnimationFrame(drawFloor);
|
||
} else {
|
||
setTimeout(initCanvases, 80);
|
||
}
|
||
}
|
||
initCanvases();
|
||
|
||
|
||
/* ============================================================
|
||
METEORS
|
||
============================================================ */
|
||
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 = `
|
||
--rot:${angle}deg;
|
||
--dist:${(window.innerHeight + len * 2)}px;
|
||
left:${x}%; top:${-len}px;
|
||
height:${len}px;
|
||
background:linear-gradient(to bottom, transparent, ${color});
|
||
animation-duration:${dur}s;
|
||
animation-name:meteorFall;
|
||
`;
|
||
document.getElementById('meteorLayer').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);
|
||
})();
|
||
|
||
|
||
|
||
/* ============================================================
|
||
VU METER (bottom of screen)
|
||
============================================================ */
|
||
const VU_COUNT = 38;
|
||
const vuMeter = document.getElementById('vuMeter');
|
||
const vuBars = [];
|
||
|
||
const VU_PALETTE = [
|
||
'#00ffcc','#00eebb','#00ddaa','#00cc99','#00bb88',
|
||
'#00aaff','#0099ee','#0088dd','#0077cc','#0066bb',
|
||
'#0099ff','#0088ee','#0077dd',
|
||
'#ff44aa','#ff55bb','#ff66cc','#ff77dd',
|
||
'#ff66cc','#ff55bb','#ff44aa',
|
||
'#0077dd','#0088ee','#0099ff',
|
||
'#0066bb','#0077cc','#0088dd','#0099ee','#00aaff',
|
||
'#00bb88','#00cc99','#00ddaa','#00eebb','#00ffcc',
|
||
'#00eebb','#00ddaa','#00cc99','#00bb88',
|
||
];
|
||
|
||
const MAX_BAR_H = 70;
|
||
|
||
for (let i = 0; i < VU_COUNT; i++) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'vu-bar';
|
||
bar.style.background = VU_PALETTE[i % VU_PALETTE.length];
|
||
bar.style.boxShadow = `0 0 4px ${VU_PALETTE[i % VU_PALETTE.length]}`;
|
||
bar.style.height = '3px';
|
||
bar.style.opacity = '0.15';
|
||
vuMeter.appendChild(bar);
|
||
vuBars.push(bar);
|
||
}
|
||
|
||
// Beat detection
|
||
const BASS_BARS = 4;
|
||
let bassSmooth = 0, bassEma = 0;
|
||
const beatWrap = document.querySelector('.beat-wrap');
|
||
const horizonEl = document.querySelector('.horizon');
|
||
|
||
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;
|
||
bassEma = bassEma * 0.96 + bass * 0.04;
|
||
}
|
||
|
||
// Called with semicolon-separated CAVA values (0–100 per bar) — SSE may fire >60fps,
|
||
// so we buffer and flush once per animation frame to avoid mid-frame DOM thrashing.
|
||
let pendingCava = null;
|
||
|
||
function updateBars(raw) {
|
||
pendingCava = raw;
|
||
}
|
||
|
||
function flushBars() {
|
||
if (pendingCava && isPlaying) {
|
||
const vals = pendingCava.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
|
||
detectBeat(vals);
|
||
vuBars.forEach((bar, i) => {
|
||
const v = vals[i] || 0;
|
||
bar.style.height = Math.max(3, (v / 100) * MAX_BAR_H) + 'px';
|
||
bar.style.opacity = 0.3 + 0.7 * (v / 100);
|
||
});
|
||
pendingCava = null;
|
||
}
|
||
requestAnimationFrame(flushBars);
|
||
}
|
||
requestAnimationFrame(flushBars);
|
||
|
||
/* ============================================================
|
||
MUSIC-LIGHT THEME (album color)
|
||
============================================================ */
|
||
let lastThemeRgb = null;
|
||
function applyTheme(rgb) {
|
||
lastThemeRgb = rgb;
|
||
const [r, g, b] = rgb;
|
||
document.documentElement.style.setProperty('--theme-color', `rgba(${r},${g},${b},1)`);
|
||
if (!chuva) {
|
||
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)`;
|
||
fogWisps.forEach(w => {
|
||
w.style.background = `radial-gradient(ellipse, rgba(${r},${g},${b},0.75) 0%, transparent 70%)`;
|
||
});
|
||
}
|
||
vuBars.forEach(bar => {
|
||
bar.style.background = `rgb(${r},${g},${b})`;
|
||
bar.style.boxShadow = `0 0 4px rgba(${r},${g},${b},0.8)`;
|
||
});
|
||
}
|
||
|
||
const albumArtEl = document.getElementById('albumArt');
|
||
const fogWisps = document.querySelectorAll('.fog-wisp');
|
||
const lyricDisplay = document.getElementById('lyricDisplay');
|
||
const lyricInner = document.getElementById('lyricInner');
|
||
const robotWrap = document.querySelector('.robot-wrap');
|
||
|
||
let lastArtUrl = '';
|
||
let showSkeleton = true;
|
||
|
||
function setShowSkeleton(val) {
|
||
showSkeleton = val;
|
||
robotWrap.style.opacity = (isPlaying && showSkeleton) ? '1' : '0';
|
||
}
|
||
|
||
/* ── verse display ── */
|
||
const ACTIVE_SHADOW =
|
||
'0 0 18px var(--theme-color,rgba(0,200,255,0.85)),' +
|
||
'0 0 6px var(--theme-color,rgba(0,200,255,0.5)),' +
|
||
'0 2px 10px rgba(0,0,0,0.95)';
|
||
|
||
const VERSE_STYLES = [
|
||
{ opacity:'0.2', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' },
|
||
{ opacity:'0.48', fontSize:'clamp(13px,1.0vw,15px)', color:'#bbb', shadow:'' },
|
||
{ opacity:'1', fontSize:'clamp(16px,1.3vw,18px)', color:'#fff', shadow: ACTIVE_SHADOW },
|
||
{ opacity:'0.48', fontSize:'clamp(13px,1.0vw,15px)', color:'#bbb', shadow:'' },
|
||
{ opacity:'0.2', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' },
|
||
];
|
||
|
||
let verseEls = [];
|
||
let currentVerseKey = '';
|
||
let currentVerseActive = -1;
|
||
|
||
function applyVerseStyle(el, dist) {
|
||
const p = VERSE_STYLES[dist + 2] || { opacity:'0', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' };
|
||
el.style.opacity = p.opacity;
|
||
el.style.fontSize = p.fontSize;
|
||
el.style.color = p.color;
|
||
el.style.textShadow = p.shadow;
|
||
}
|
||
|
||
function centerActive(verse_active) {
|
||
const activeEl = verseEls[verse_active];
|
||
if (!activeEl) return;
|
||
const y = lyricDisplay.offsetHeight / 2 - activeEl.offsetHeight / 2 - activeEl.offsetTop;
|
||
lyricInner.style.transform = `translateY(${y}px)`;
|
||
}
|
||
|
||
function positionVerseEls(verse_active) {
|
||
verseEls.forEach((el, i) => applyVerseStyle(el, i - verse_active));
|
||
centerActive(verse_active);
|
||
}
|
||
|
||
function buildVerse(verse_lines, verse_active, onReady) {
|
||
lyricInner.innerHTML = '';
|
||
lyricInner.style.transition = 'none';
|
||
lyricInner.style.transform = 'translateY(0)';
|
||
verseEls = verse_lines.map(text => {
|
||
const el = document.createElement('div');
|
||
el.className = 'lyric-ctx';
|
||
el.textContent = text;
|
||
el.style.transition = 'none';
|
||
lyricInner.appendChild(el);
|
||
return el;
|
||
});
|
||
verseEls.forEach((el, i) => applyVerseStyle(el, i - verse_active));
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
lyricInner.style.transition = 'none';
|
||
centerActive(verse_active);
|
||
verseEls.forEach(el => { el.style.transition = ''; });
|
||
requestAnimationFrame(() => {
|
||
lyricInner.style.transition = '';
|
||
if (onReady) onReady();
|
||
});
|
||
}));
|
||
}
|
||
|
||
function updateLyricVerse(verse_lines, verse_active) {
|
||
if (!verse_lines || !verse_lines.length || verse_active < 0 || !isPlaying) {
|
||
lyricDisplay.style.opacity = '0';
|
||
currentVerseKey = '';
|
||
currentVerseActive = -1;
|
||
return;
|
||
}
|
||
const verseKey = verse_lines.join('\x00');
|
||
|
||
if (verseKey !== currentVerseKey) {
|
||
currentVerseKey = verseKey;
|
||
currentVerseActive = verse_active;
|
||
if (lyricDisplay.style.opacity === '1') {
|
||
lyricDisplay.style.opacity = '0';
|
||
setTimeout(() => {
|
||
buildVerse(verse_lines, verse_active, () => {
|
||
lyricDisplay.style.opacity = '1';
|
||
});
|
||
}, 500);
|
||
} else {
|
||
buildVerse(verse_lines, verse_active, () => {
|
||
lyricDisplay.style.opacity = '1';
|
||
});
|
||
}
|
||
} else if (verse_active !== currentVerseActive) {
|
||
currentVerseActive = verse_active;
|
||
positionVerseEls(verse_active);
|
||
} else {
|
||
lyricDisplay.style.opacity = '1';
|
||
}
|
||
}
|
||
|
||
function fetchCurrent() {
|
||
fetch('http://127.0.0.1:8765/current')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.rgb) applyTheme(d.rgb);
|
||
if (d.art_url && d.art_url !== lastArtUrl) {
|
||
lastArtUrl = d.art_url;
|
||
document.getElementById('albumArtImg').src =
|
||
'http://127.0.0.1:8765/art?' + Date.now();
|
||
}
|
||
updateLyricVerse(d.verse_lines || [], d.verse_active ?? -1);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
setInterval(() => { if (isPlaying) fetchCurrent(); }, 700);
|
||
|
||
// Connect to CAVA SSE server (started by cava-start.sh via QML)
|
||
(function connectCava() {
|
||
const sse = new EventSource('http://127.0.0.1:5555');
|
||
sse.onmessage = e => { if (e.data) updateBars(e.data); };
|
||
sse.onerror = () => { sse.close(); setTimeout(connectCava, 3000); };
|
||
})();
|
||
|
||
/* ============================================================
|
||
FLOATING MUSIC NOTES
|
||
============================================================ */
|
||
const NOTES = ['♪','♫','♩','♬','♭','♮'];
|
||
const N_CLR = ['#ff44aa','#ff88cc','#cc44ff','#ff66bb','#aa44ff','#ff55dd'];
|
||
const scene = document.querySelector('.scene');
|
||
|
||
function spawnNote() {
|
||
const el = document.createElement('div');
|
||
el.className = 'music-note';
|
||
el.textContent = NOTES[Math.floor(Math.random() * NOTES.length)];
|
||
const clr = N_CLR[Math.floor(Math.random() * N_CLR.length)];
|
||
el.style.color = clr;
|
||
el.style.textShadow = `0 0 12px ${clr}, 0 0 24px ${clr}`;
|
||
el.style.fontSize = (18 + Math.random() * 18) + 'px';
|
||
/* Scatter notes around the robot */
|
||
el.style.left = (42 + Math.random() * 16) + '%';
|
||
el.style.bottom = (42 + Math.random() * 6) + '%';
|
||
const dur = 1.6 + Math.random() * 1.2;
|
||
el.style.animationDuration = dur + 's';
|
||
scene.appendChild(el);
|
||
setTimeout(() => el.remove(), dur * 1000 + 100);
|
||
}
|
||
|
||
let isPlaying = false;
|
||
|
||
/* Spawn on every beat (500ms) with a 50 % chance of two notes */
|
||
setInterval(() => {
|
||
if (!isPlaying || !showSkeleton) return;
|
||
spawnNote();
|
||
if (Math.random() < 0.5) spawnNote();
|
||
}, BEAT_MS);
|
||
|
||
/* ============================================================
|
||
MUSIC INFO (called by QML via runJavaScript)
|
||
============================================================ */
|
||
function updateMusic(artist, title) {
|
||
const el = document.getElementById('musicText');
|
||
const artEl = document.getElementById('musicArtist');
|
||
const titleEl = document.getElementById('musicTitle');
|
||
const sepEl = document.getElementById('musicSep');
|
||
const playing = !!(artist || title);
|
||
isPlaying = playing;
|
||
robotWrap.style.opacity = (playing && showSkeleton) ? '1' : '0';
|
||
document.querySelector('.music-ticker').style.opacity = playing ? '1' : '0';
|
||
albumArtEl.style.opacity = playing ? '1' : '0';
|
||
if (!playing) {
|
||
vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; });
|
||
lyricDisplay.style.opacity = '0';
|
||
currentVerseKey = '';
|
||
currentVerseActive = -1;
|
||
}
|
||
if (playing) fetchCurrent();
|
||
const key = artist + '|' + title;
|
||
if (el.dataset.key === key) return;
|
||
el.dataset.key = key;
|
||
artEl.textContent = artist || '';
|
||
sepEl.textContent = (artist && title) ? ' — ' : '';
|
||
titleEl.textContent = title || (!artist ? '— —' : '');
|
||
if (playing) pickSkeleton();
|
||
const wrap = el.parentElement;
|
||
el.classList.toggle('short', el.scrollWidth <= wrap.clientWidth + 10);
|
||
}
|
||
|
||
/* ============================================================
|
||
DANCE STYLE MACHINE
|
||
Cycles: bounce → wave → shuffle → moonwalk → spin → repeat
|
||
Each style lasts 8s (= 16 beats at 120 BPM)
|
||
============================================================ */
|
||
const DANCE_STYLES = ['bounce', 'wave', 'shuffle', 'moonwalk', 'spin'];
|
||
const STYLE_LABELS = ['BOUNCE', 'WAVE', 'SHUFFLE', 'MOONWALK', 'SPIN'];
|
||
let danceIdx = 0;
|
||
|
||
function nextDanceStyle() {
|
||
danceIdx = (danceIdx + 1) % DANCE_STYLES.length;
|
||
document.body.setAttribute('data-dance', DANCE_STYLES[danceIdx]);
|
||
}
|
||
|
||
// Start on default bounce
|
||
document.body.setAttribute('data-dance', 'bounce');
|
||
setInterval(nextDanceStyle, 8000);
|
||
|
||
function pickSkeleton() {}
|
||
|
||
|
||
/* ============================================================
|
||
BALADA MODE
|
||
============================================================ */
|
||
let balada = false;
|
||
|
||
const sunEl = document.querySelector('.synthwave-sun');
|
||
|
||
/* Listras orgânicas do sol — perspectiva compressa: mais densas em baixo, esparsas em cima.
|
||
Distribuição sqrt(i/(N+1)): primeira listra ~22% do topo, última ~95%, gap diminuindo para baixo. */
|
||
function buildSunStripes() {
|
||
const el = document.getElementById('sunStripes');
|
||
const N = 28;
|
||
const stops = ['transparent 0%'];
|
||
|
||
for (let i = 1; i <= N; i++) {
|
||
// sqrt(i/N): primeira listra em ~19% do topo, última exatamente em 100%
|
||
const t = Math.sqrt(i / N);
|
||
const pct = 100 * t;
|
||
const frac = (i - 1) / (N - 1); // 0→1 linear para rampas
|
||
|
||
// Espessura: 0.7px (fino no topo) → 6px (sólido no fundo)
|
||
const wPx = 0.7 + 5.3 * frac;
|
||
const wPct = wPx / 280 * 100;
|
||
|
||
// Opacidade: 0.14 (suave mas visível no topo) → 0.72 no fundo, raiz para não ser flat
|
||
const a = (0.14 + 0.58 * Math.sqrt(frac)).toFixed(2);
|
||
|
||
const p1 = Math.max(0, pct - wPct / 2).toFixed(3);
|
||
const p2 = Math.min(100, pct + wPct / 2).toFixed(3);
|
||
|
||
stops.push(`transparent ${p1}%`);
|
||
stops.push(`rgba(14,0,30,${a}) ${p1}%`);
|
||
stops.push(`rgba(14,0,30,${a}) ${p2}%`);
|
||
stops.push(`transparent ${p2}%`);
|
||
}
|
||
|
||
stops.push('transparent 100%');
|
||
el.style.background = `linear-gradient(to bottom,${stops.join(',')})`;
|
||
}
|
||
buildSunStripes();
|
||
const sunRaysWrap = document.getElementById('sunRaysWrap');
|
||
|
||
/* ── Canvas do globo — latitude/longitude com curvatura real ── */
|
||
const globeCanvas = document.createElement('canvas');
|
||
globeCanvas.width = 280;
|
||
globeCanvas.height = 280;
|
||
globeCanvas.style.cssText =
|
||
'position:absolute;inset:0;border-radius:50%;pointer-events:none;' +
|
||
'opacity:0;transition:opacity 1.5s ease;';
|
||
sunEl.appendChild(globeCanvas);
|
||
const gctx = globeCanvas.getContext('2d');
|
||
let globeRot = 0, lastGlobeTs = 0, globeLoopOn = false;
|
||
|
||
/* Loop roda só enquanto o globo é visível — dorme quando baladaAmt zera */
|
||
function startGlobeLoop() {
|
||
if (globeLoopOn) return;
|
||
globeLoopOn = true;
|
||
lastGlobeTs = 0;
|
||
requestAnimationFrame(drawGlobe);
|
||
}
|
||
|
||
/* 8 luzes nos eixos ortogonais — cobertura 360° garantida.
|
||
±Y não são afetados pelo lrot, ±X e ±Z orbitam entre si. */
|
||
const MIRROR_LIGHTS = [
|
||
{ x: 1, y: 0, z: 0, r: 1, g: 205, b: 254 }, // cyan +X
|
||
{ x: -1, y: 0, z: 0, r: 185, g: 103, b: 255 }, // purple -X
|
||
{ x: 0, y: 1, z: 0, r: 255, g: 113, b: 206 }, // pink +Y (topo — fixo)
|
||
{ x: 0, y: -1, z: 0, r: 5, g: 255, b: 161 }, // mint -Y (base — fixo)
|
||
{ x: 0, y: 0, z: 1, r: 255, g: 255, b: 255 }, // branco +Z
|
||
{ x: 0, y: 0, z: -1, r: 255, g: 251, b: 150 }, // amarelo -Z
|
||
{ x: 0.71, y: 0.71, z: 0, r: 255, g: 113, b: 206 }, // pink diagonal alta
|
||
{ x: -0.71, y: -0.71, z: 0, r: 5, g: 255, b: 161 }, // mint diagonal baixa
|
||
];
|
||
|
||
function drawGlobe(ts) {
|
||
if (!balada && baladaAmt < 0.01) { globeLoopOn = false; lastGlobeTs = 0; return; }
|
||
requestAnimationFrame(drawGlobe);
|
||
if (baladaAmt < 0.01) { lastGlobeTs = 0; return; }
|
||
globeCanvas.style.opacity = chuva ? 0 : baladaAmt;
|
||
const dt = lastGlobeTs ? Math.min((ts - lastGlobeTs) / 1000, 0.05) : 0;
|
||
lastGlobeTs = ts;
|
||
globeRot = (globeRot + dt * 0.5) % (Math.PI * 2);
|
||
|
||
const N_LAT = 14;
|
||
const BASE_LON = 28;
|
||
const FILL = 0.78;
|
||
const SHINE = 8; // expoente baixo → highlights amplos, muitos tiles acesos
|
||
|
||
const R = 137, cx = 140, cy = 140;
|
||
gctx.clearRect(0, 0, 280, 280);
|
||
gctx.save();
|
||
gctx.beginPath();
|
||
gctx.arc(cx, cy, R, 0, Math.PI * 2);
|
||
gctx.clip();
|
||
gctx.fillStyle = '#06000c';
|
||
gctx.fillRect(0, 0, 280, 280);
|
||
|
||
for (let lat = 0; lat < N_LAT; lat++) {
|
||
const phi1 = -Math.PI / 2 + (lat / N_LAT) * Math.PI;
|
||
const phi2 = -Math.PI / 2 + ((lat + 1) / N_LAT) * Math.PI;
|
||
const phiC = (phi1 + phi2) / 2;
|
||
const cosP = Math.cos(phiC), sinP = Math.sin(phiC);
|
||
const nLon = Math.max(3, Math.round(BASE_LON * cosP));
|
||
|
||
for (let lon = 0; lon < nLon; lon++) {
|
||
const th1 = globeRot + (lon / nLon) * Math.PI * 2;
|
||
const th2 = globeRot + ((lon + 1) / nLon) * Math.PI * 2;
|
||
const thC = (th1 + th2) / 2;
|
||
|
||
const nx = cosP * Math.sin(thC);
|
||
const ny = sinP;
|
||
const nz = cosP * Math.cos(thC);
|
||
if (nz <= 0.02) continue;
|
||
|
||
// Vetor refletido da câmera (0,0,1) sobre a normal do tile
|
||
const rx = 2 * nz * nx;
|
||
const ry = 2 * nz * ny;
|
||
const rz = 2 * nz * nz - 1;
|
||
|
||
const lrot = ts * 0.000026;
|
||
let bestDot = 0, bestR = 0, bestG = 0, bestB = 0;
|
||
let dr = 0, dg = 0, db = 0;
|
||
for (const L of MIRROR_LIGHTS) {
|
||
const lx = L.x * Math.cos(lrot) - L.z * Math.sin(lrot);
|
||
const lz = L.x * Math.sin(lrot) + L.z * Math.cos(lrot);
|
||
const spec = rx * lx + ry * L.y + rz * lz;
|
||
if (spec > bestDot) { bestDot = spec; bestR = L.r; bestG = L.g; bestB = L.b; }
|
||
const diff = Math.max(0, nx * lx + ny * L.y + nz * lz);
|
||
dr += diff * L.r * 0.07; dg += diff * L.g * 0.07; db += diff * L.b * 0.07;
|
||
}
|
||
const s = bestDot > 0.2 ? Math.pow(bestDot, SHINE) : 0;
|
||
const amb = 0.18 + 0.08 * nz;
|
||
let cr = amb * 100 + dr + s * bestR;
|
||
let cg = amb * 65 + dg + s * bestG;
|
||
let cb = amb * 170 + db + s * bestB;
|
||
|
||
const dP = (phi2 - phi1) * FILL, oP = (phi2 - phi1) * (1 - FILL) / 2;
|
||
const dTh = (th2 - th1) * FILL, oTh = (th2 - th1) * (1 - FILL) / 2;
|
||
const pts = [];
|
||
for (let ci = 0; ci < 4; ci++) {
|
||
const p = phi1 + oP + (ci >> 1) * dP;
|
||
const th = th1 + oTh + (ci & 1) * dTh;
|
||
pts.push([cx + R * Math.cos(p) * Math.sin(th), cy - R * Math.sin(p)]);
|
||
}
|
||
|
||
gctx.fillStyle = `rgb(${Math.min(255,cr)|0},${Math.min(255,cg)|0},${Math.min(255,cb)|0})`;
|
||
gctx.beginPath();
|
||
gctx.moveTo(pts[0][0], pts[0][1]);
|
||
gctx.lineTo(pts[1][0], pts[1][1]);
|
||
gctx.lineTo(pts[3][0], pts[3][1]);
|
||
gctx.lineTo(pts[2][0], pts[2][1]);
|
||
gctx.closePath();
|
||
gctx.fill();
|
||
}
|
||
}
|
||
|
||
gctx.restore();
|
||
}
|
||
const discoDots = document.getElementById('discoDots');
|
||
const strobeEl = document.getElementById('strobe');
|
||
|
||
/* ── Raios do sol/disco — paleta vaporwave ── */
|
||
const RAY_COLORS = [
|
||
'#ff71ce','#b967ff','#01cdfe',
|
||
'#05ffa1','#fffb96','#b967ff',
|
||
'#ff71ce','#01cdfe','#b967ff','#ff71ce'
|
||
];
|
||
(function buildRays() {
|
||
for (let i = 0; i < RAY_COLORS.length; i++) {
|
||
const r = document.createElement('div');
|
||
r.className = 'sun-ray';
|
||
const col = RAY_COLORS[i];
|
||
r.style.width = '95vmax';
|
||
r.style.height = '18px';
|
||
r.style.top = '-9px';
|
||
r.style.transform = `rotate(${(i / RAY_COLORS.length) * 360}deg)`;
|
||
/* gradiente suave — sem borda dura */
|
||
r.style.background =
|
||
`linear-gradient(to right, ${col}28 0%, ${col}12 45%, transparent 80%)`;
|
||
r.style.filter = 'blur(3px)';
|
||
sunRaysWrap.appendChild(r);
|
||
}
|
||
})();
|
||
|
||
/* Rotação contínua dos raios */
|
||
(function animateRays(ts) {
|
||
if (balada) sunRaysWrap.style.transform = `rotate(${(ts * 0.025) % 360}deg)`;
|
||
requestAnimationFrame(animateRays);
|
||
})(0);
|
||
|
||
/* ── Partículas de luz disparadas do sol ── */
|
||
function spawnDiscoDot() {
|
||
const d = document.createElement('div');
|
||
d.className = 'disco-dot';
|
||
const col = RAY_COLORS[Math.floor(Math.random() * RAY_COLORS.length)];
|
||
const sz = 4 + Math.random() * 7;
|
||
const dur = 1.4 + Math.random() * 2;
|
||
const ox = 48 + Math.random() * 4;
|
||
const oy = 22 + Math.random() * 4;
|
||
const s = () => (Math.random() - 0.5) * 2;
|
||
d.style.cssText =
|
||
`width:${sz}px;height:${sz}px;` +
|
||
`background:${col};box-shadow:0 0 ${sz * 1.5}px ${col};` +
|
||
`left:${ox}vw;top:${oy}vh;` +
|
||
`--tx1:${s()*12}vw;--ty1:${s()*8}vh;` +
|
||
`--tx2:${s()*35}vw;--ty2:${5 + Math.random() * 30}vh;` +
|
||
`--tx3:${s()*65}vw;--ty3:${15 + Math.random() * 55}vh;` +
|
||
`animation-duration:${dur}s;`;
|
||
discoDots.appendChild(d);
|
||
setTimeout(() => d.remove(), dur * 1000 + 100);
|
||
}
|
||
setInterval(() => {
|
||
if (!balada) return;
|
||
if (baladaAmt > 0.3 && discoDots.childElementCount < 28) spawnDiscoDot();
|
||
}, 280);
|
||
|
||
/* ── Pulso do sol/disco no beat ── */
|
||
function globeBeat() {
|
||
if (!balada || !sunEl) return;
|
||
sunEl.style.boxShadow =
|
||
'0 0 14px 3px rgba(255,113,206,0.08),' +
|
||
'0 0 30px 6px rgba(185,103,255,0.05)';
|
||
setTimeout(() => { sunEl.style.boxShadow = ''; }, 1200);
|
||
}
|
||
setInterval(() => { if (balada) globeBeat(); }, BEAT_MS);
|
||
|
||
/* ── Toggle ── */
|
||
function setBaladaMode(on) {
|
||
if (on === balada) return;
|
||
balada = on;
|
||
if (on) { setChuvaMode(false); startGlobeLoop(); }
|
||
document.body.classList.toggle('balada', balada);
|
||
/* opacidade do globo é controlada por drawGlobe via baladaAmt */
|
||
}
|
||
function toggleBalada() { setBaladaMode(!balada); }
|
||
|
||
/* Polling de arquivos — só reage a MUDANÇAS no arquivo; um arquivo parado
|
||
em "1" não fica religando o modo e brigando com o outro toggle.
|
||
A primeira leitura é só baseline: arquivo velho não sobrescreve a config. */
|
||
let baladaFileVal = null;
|
||
(function pollBaladaFile() {
|
||
fetch('file:///tmp/skeledance-balada?t=' + Date.now())
|
||
.then(r => r.text())
|
||
.then(t => {
|
||
const v = t.trim();
|
||
if (v === baladaFileVal) return;
|
||
const first = baladaFileVal === null;
|
||
baladaFileVal = v;
|
||
if (!first) setBaladaMode(v === '1');
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setTimeout(pollBaladaFile, 800));
|
||
})();
|
||
|
||
(function pollSkeletonFile() {
|
||
fetch('file:///tmp/skeledance-skeleton?t=' + Date.now())
|
||
.then(r => r.text())
|
||
.then(t => setShowSkeleton(t.trim() !== '0'))
|
||
.catch(() => {})
|
||
.finally(() => setTimeout(pollSkeletonFile, 800));
|
||
})();
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (!e.target.matches('input,textarea')) {
|
||
if (e.key === 'b' || e.key === 'B') toggleBalada();
|
||
if (e.key === 'r' || e.key === 'R') toggleChuva();
|
||
}
|
||
});
|
||
|
||
|
||
/* ============================================================
|
||
CHUVA MODE
|
||
============================================================ */
|
||
let chuva = false;
|
||
let chuvaAmt = 0;
|
||
|
||
const rainCanvas = document.getElementById('rainCanvas');
|
||
const rctx = rainCanvas.getContext('2d');
|
||
|
||
const RAIN_COUNT = 280;
|
||
const rainDrops = [];
|
||
const RAIN_ANGLE = Math.PI / 13; // ~14° da vertical
|
||
const RAIN_DX = Math.sin(RAIN_ANGLE);
|
||
const RAIN_DY = Math.cos(RAIN_ANGLE);
|
||
|
||
function resizeRain() {
|
||
rainCanvas.width = window.innerWidth;
|
||
rainCanvas.height = window.innerHeight;
|
||
}
|
||
window.addEventListener('resize', resizeRain);
|
||
|
||
function initRain() {
|
||
const W = rainCanvas.width || window.innerWidth;
|
||
const H = rainCanvas.height || window.innerHeight;
|
||
rainDrops.length = 0;
|
||
for (let i = 0; i < RAIN_COUNT; i++) {
|
||
rainDrops.push({
|
||
x: -60 + Math.random() * (W + 120),
|
||
y: Math.random() * H,
|
||
len: 11 + Math.random() * 18,
|
||
speed: 440 + Math.random() * 360,
|
||
alpha: 0.3 + Math.random() * 0.45,
|
||
w: 0.8 + Math.random() * 0.8,
|
||
});
|
||
}
|
||
}
|
||
|
||
let lastRainTs = 0;
|
||
let rainLoopOn = false;
|
||
|
||
/* Loop roda só enquanto há chuva visível — dorme quando chuvaAmt zera */
|
||
function startRainLoop() {
|
||
if (rainLoopOn) return;
|
||
rainLoopOn = true;
|
||
lastRainTs = 0;
|
||
requestAnimationFrame(drawRain);
|
||
}
|
||
|
||
function drawRain(ts) {
|
||
const dt = lastRainTs ? Math.min((ts - lastRainTs) / 1000, 0.05) : 0;
|
||
lastRainTs = ts;
|
||
chuvaAmt += ((chuva ? 1 : 0) - chuvaAmt) * Math.min(1, dt * 0.5);
|
||
|
||
const W = rainCanvas.width, H = rainCanvas.height;
|
||
rctx.clearRect(0, 0, W, H);
|
||
if (!chuva && chuvaAmt < 0.005) { rainLoopOn = false; return; }
|
||
requestAnimationFrame(drawRain);
|
||
if (chuvaAmt < 0.005) return;
|
||
|
||
rainDrops.forEach(d => {
|
||
d.x += d.speed * dt * RAIN_DX;
|
||
d.y += d.speed * dt * RAIN_DY;
|
||
if (d.y > H + d.len || d.x > W + d.len) {
|
||
d.y = -d.len - Math.random() * 80;
|
||
d.x = -60 + Math.random() * (W + 120);
|
||
}
|
||
rctx.beginPath();
|
||
rctx.moveTo(d.x, d.y);
|
||
rctx.lineTo(d.x + d.len * RAIN_DX, d.y + d.len * RAIN_DY);
|
||
rctx.strokeStyle = `rgba(160,195,235,${(d.alpha * chuvaAmt).toFixed(3)})`;
|
||
rctx.lineWidth = d.w;
|
||
rctx.stroke();
|
||
});
|
||
}
|
||
|
||
/* ── Relâmpago ── */
|
||
function triggerLightning() {
|
||
if (!chuva) return;
|
||
const flashes = 1 + Math.floor(Math.random() * 3);
|
||
let delay = 0;
|
||
for (let i = 0; i < flashes; i++) {
|
||
const intensity = (0.5 + Math.random() * 0.32).toFixed(2);
|
||
setTimeout(() => {
|
||
strobeEl.style.transition = 'none';
|
||
strobeEl.style.background = 'rgba(210,228,255,1)';
|
||
strobeEl.style.opacity = intensity;
|
||
setTimeout(() => {
|
||
strobeEl.style.transition = 'opacity 0.2s ease';
|
||
strobeEl.style.opacity = '0';
|
||
}, 50 + Math.random() * 30 | 0);
|
||
}, delay);
|
||
delay += 100 + (Math.random() * 110 | 0);
|
||
}
|
||
/* limpa o tom azulado do strobe depois da rajada */
|
||
setTimeout(() => { strobeEl.style.background = ''; }, delay + 300);
|
||
}
|
||
|
||
(function scheduleLightning() {
|
||
if (chuva) triggerLightning();
|
||
setTimeout(scheduleLightning, 10000 + Math.random() * 22000 | 0);
|
||
})();
|
||
|
||
/* ── Toggle ── */
|
||
function setChuvaMode(on) {
|
||
if (on === chuva) return;
|
||
chuva = on;
|
||
if (on) { setBaladaMode(false); startRainLoop(); }
|
||
document.body.classList.toggle('chuva', chuva);
|
||
if (on) {
|
||
fogWisps.forEach(w => {
|
||
w.style.background =
|
||
'radial-gradient(ellipse, rgba(70,100,160,0.5) 0%, transparent 70%)';
|
||
});
|
||
} else if (lastThemeRgb) {
|
||
applyTheme(lastThemeRgb); // devolve a cor do álbum ao fog/horizonte
|
||
}
|
||
}
|
||
function toggleChuva() { setChuvaMode(!chuva); }
|
||
|
||
let chuvaFileVal = null;
|
||
(function pollChuvaFile() {
|
||
fetch('file:///tmp/skeledance-chuva?t=' + Date.now())
|
||
.then(r => r.text())
|
||
.then(t => {
|
||
const v = t.trim();
|
||
if (v === chuvaFileVal) return;
|
||
const first = chuvaFileVal === null;
|
||
chuvaFileVal = v;
|
||
if (!first) setChuvaMode(v === '1');
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setTimeout(pollChuvaFile, 800));
|
||
})();
|
||
|
||
/* ── Inicializa rain canvas (separado de initCanvases pois roda após as declarações) ── */
|
||
(function initRainOnLoad() {
|
||
if (window.innerWidth > 0 && window.innerHeight > 0) {
|
||
resizeRain();
|
||
initRain();
|
||
if (chuva) startRainLoop();
|
||
} else {
|
||
setTimeout(initRainOnLoad, 80);
|
||
}
|
||
})();
|
||
|
||
/* ============================================================
|
||
DEV SHIM — only active when served via HTTP (not from QML)
|
||
Simulates playerctl updates so the music ticker works in-browser.
|
||
============================================================ */
|
||
if (window.location.protocol !== 'file:' && !window.qt) {
|
||
const devTracks = [
|
||
['Daft Punk', 'Get Lucky'],
|
||
['Kavinsky', 'Nightcall'],
|
||
['Justice', 'D.A.N.C.E.'],
|
||
['M83', 'Midnight City'],
|
||
['Chromatics', 'Shadow'],
|
||
];
|
||
let devIdx = 0;
|
||
function devTick() {
|
||
const [artist, title] = devTracks[devIdx % devTracks.length];
|
||
updateMusic(artist, title);
|
||
devIdx++;
|
||
}
|
||
devTick();
|
||
setInterval(devTick, 5000);
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|