Via Láctea em três camadas ancoradas em coordenadas galácticas reais: véu difuso (3600 blobs) com gradiente térmico — creme-âmbar no núcleo, azul no anticentro —, grão estelar fino (9500 pontos sub-pixel) e estrutura: bojo central, Grande Fenda + faixa de poeira secundária, 14 nuvens estelares (Scutum/Sagitário/Carina). Layout: capa do álbum vai para a esquerda, letra para a direita (alinhada à direita). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015HtPHsTnDcLVSCiF5FGHZj
2437 lines
77 KiB
HTML
2437 lines
77 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%;
|
||
left: 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;
|
||
right: 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%;
|
||
text-align: right; /* letra ancorada à direita da tela */
|
||
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 src="starcatalog.js"></script>
|
||
<script>
|
||
/* ============================================================
|
||
CONSTANTS
|
||
120 BPM → beat = 500ms. All timings derived from this.
|
||
============================================================ */
|
||
const BPM = 120;
|
||
const BEAT_MS = 60000 / BPM; // 500ms
|
||
|
||
/* ============================================================
|
||
CÉU REALISTA — Yale BSC (starcatalog.js) + Via Láctea
|
||
Posições reais para o observador via tempo sideral local.
|
||
O sol vaporwave é cenográfico — só a camada de estrelas é real.
|
||
============================================================ */
|
||
const canvas = document.getElementById('starfield');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
let obsLat = -24.72, obsLon = -53.74; // Toledo-PR; sobrescrito pela config
|
||
const VIEW_AZ = 90; // centro da tela olha para o LESTE — a tela física do
|
||
// usuário está voltada p/ leste, então o wallpaper age
|
||
// como janela: estrelas nascem subindo dentro da cena
|
||
const FOV_AZ = 140; // largura da tela em graus de azimute
|
||
const ALT_TOP = 75; // altitude no topo da tela
|
||
const HORIZON_F = 0.585; // fração da altura da tela até o horizonte
|
||
const TWINKLE_MAG = 4.0; // até esta magnitude a estrela cintila; acima é estática
|
||
const DEG = Math.PI / 180;
|
||
window.__skyTimeOffset = 0; // debug: ms somados ao relógio p/ testar a rotação
|
||
|
||
/* Tempo sideral local em graus (GMST + longitude leste) */
|
||
function localSiderealDeg(ms) {
|
||
const d = ms / 86400000 - 10957.5; // dias desde J2000.0
|
||
return ((280.46061837 + 360.98564736629 * d + obsLon) % 360 + 360) % 360;
|
||
}
|
||
|
||
/* RA/Dec (graus) -> altitude/azimute (graus, az a partir do norte p/ leste) */
|
||
function altAz(raDeg, decDeg, lstDeg) {
|
||
const H = (lstDeg - raDeg) * DEG;
|
||
const cd = Math.cos(decDeg * DEG), sd = Math.sin(decDeg * DEG);
|
||
const cl = Math.cos(obsLat * DEG), sl = Math.sin(obsLat * DEG);
|
||
const xh = -cd * Math.cos(H) * sl + sd * cl; // norte
|
||
const yh = -cd * Math.sin(H); // leste
|
||
const zh = cd * Math.cos(H) * cl + sd * sl; // zênite
|
||
return { alt: Math.asin(zh) / DEG, az: Math.atan2(yh, xh) / DEG };
|
||
}
|
||
|
||
/* Projeção cilíndrica: azimute -> x, altitude -> y. null = fora da tela */
|
||
function skyToScreen(alt, az, W, horizonPx) {
|
||
const daz = ((az - VIEW_AZ) % 360 + 540) % 360 - 180;
|
||
if (Math.abs(daz) > FOV_AZ / 2 || alt < -3 || alt > ALT_TOP) return null;
|
||
return { x: (0.5 + daz / FOV_AZ) * W, y: horizonPx * (1 - alt / ALT_TOP) };
|
||
}
|
||
|
||
/* Extinção atmosférica: estrelas somem suavemente perto do horizonte */
|
||
function extinction(alt) {
|
||
return Math.max(0, Math.min(1, (alt + 1) / 14));
|
||
}
|
||
|
||
/* Clarão ao redor do sol retrowave (só no modo padrão — nos outros o sol
|
||
sobe/encolhe e o próprio elemento cobre o que está atrás) */
|
||
function glareFactor(x, y, W, H, on) {
|
||
if (!on) return 1;
|
||
const d = Math.hypot(x - W / 2, y - H * HORIZON_F);
|
||
return Math.max(0, Math.min(1, (d - 150) / 240));
|
||
}
|
||
|
||
/* ── Via Láctea: partículas geradas em coords galácticas (RNG com semente,
|
||
estável entre reloads) e convertidas p/ equatorial J2000 uma única vez ── */
|
||
function mulberry32(seed) {
|
||
return function () {
|
||
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
|
||
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/* Transposta da matriz equatorial->galáctica (Hipparcos, J2000) */
|
||
const G2E = [
|
||
[-0.0548755604, 0.4941094279, -0.8676661490],
|
||
[-0.8734370902, -0.4448296300, -0.1980763734],
|
||
[-0.4838350155, 0.7469822445, 0.4559837762],
|
||
];
|
||
|
||
/* Visual de astrofoto de longa exposição: grão estelar fino + brilho difuso
|
||
com gradiente térmico (núcleo âmbar -> anticentro azul) + faixas de poeira */
|
||
function galToEq(l, b) {
|
||
const cb = Math.cos(b * DEG);
|
||
const gx = cb * Math.cos(l * DEG), gy = cb * Math.sin(l * DEG), gz = Math.sin(b * DEG);
|
||
return {
|
||
ra: (Math.atan2(
|
||
G2E[1][0] * gx + G2E[1][1] * gy + G2E[1][2] * gz,
|
||
G2E[0][0] * gx + G2E[0][1] * gy + G2E[0][2] * gz) / DEG + 360) % 360,
|
||
dec: Math.asin(G2E[2][0] * gx + G2E[2][1] * gy + G2E[2][2] * gz) / DEG,
|
||
};
|
||
}
|
||
|
||
/* Cor por distância angular ao centro galáctico: creme-âmbar -> neutro -> azul */
|
||
function mwColor(dl) {
|
||
const t = Math.min(1, dl / 140);
|
||
const mix = (a, b2, c) => Math.round(t < 0.5 ? a + (b2 - a) * t * 2 : b2 + (c - b2) * (t - 0.5) * 2);
|
||
return [mix(255, 236, 186), mix(226, 224, 205), mix(188, 214, 242)];
|
||
}
|
||
|
||
/* Faixas de poeira (Grande Fenda + secundária): 1 = livre, ~0.1 = dentro da poeira */
|
||
function mwDust(l, b, dl) {
|
||
if (dl > 100) return 1;
|
||
const fade = dl > 70 ? (100 - dl) / 30 : 1; // fenda some longe do centro
|
||
const lane1 = Math.abs(b - 1.6 * Math.sin(l * DEG * 2.2)) <
|
||
(2.2 + 1.2 * Math.sin(l * 0.7)) * fade;
|
||
const lane2 = Math.abs(b + 2.6 - 1.2 * Math.sin(l * DEG * 1.4 + 1.7)) <
|
||
(1.3 + 0.8 * Math.sin(l * 1.3 + 0.5)) * fade;
|
||
return (lane1 || lane2) ? 0.1 : 1;
|
||
}
|
||
|
||
/* Nuvens estelares (Scutum, Sagitário, Carina...) — adensamentos fixos na banda */
|
||
const MW_CLOUDS = [];
|
||
const mwGlow = []; // blobs difusos {ra,dec,size,alpha,ci}
|
||
const mwGrain = []; // grão estelar fino {ra,dec,alpha,rgb}
|
||
(function buildMilkyWay() {
|
||
const rand = mulberry32(0x5EEDED);
|
||
const gauss = () => {
|
||
const u = Math.max(rand(), 1e-9), v = rand();
|
||
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
||
};
|
||
for (let i = 0; i < 14; i++) {
|
||
const l = (rand() * 160 - 80 + 360) % 360; // nuvens no lado do centro
|
||
MW_CLOUDS.push({ l, b: gauss() * 2.5, r: 2 + rand() * 4 });
|
||
}
|
||
const cloudBoost = (l, b) => {
|
||
let boost = 0;
|
||
for (const c of MW_CLOUDS) {
|
||
const dlc = Math.min(Math.abs(l - c.l), 360 - Math.abs(l - c.l));
|
||
const d2 = (dlc / c.r) ** 2 + ((b - c.b) / c.r) ** 2;
|
||
if (d2 < 1) boost = Math.max(boost, 1 - d2);
|
||
}
|
||
return boost;
|
||
};
|
||
|
||
let guard = 0;
|
||
while (mwGlow.length < 3600 && guard++ < 300000) {
|
||
const l = rand() * 360;
|
||
const dl = Math.min(l, 360 - l);
|
||
const central = Math.exp(-((dl / 75) ** 2));
|
||
if (rand() > 0.28 + 0.72 * central) continue;
|
||
/* bojo central: banda mais gorda perto do núcleo */
|
||
const b = gauss() * (5 + 3 * central + 4 * Math.exp(-((dl / 22) ** 2)));
|
||
const dust = mwDust(l, b, dl);
|
||
const boost = cloudBoost(l, b);
|
||
const alpha = (0.35 + 0.65 * central) * dust * (1 + 1.2 * boost) *
|
||
(0.028 + rand() * 0.05);
|
||
if (alpha < 0.005) continue;
|
||
const eq = galToEq(l, b);
|
||
mwGlow.push({ ra: eq.ra, dec: eq.dec, size: 14 + rand() * 30,
|
||
alpha, ci: mwColor(dl) });
|
||
}
|
||
|
||
guard = 0;
|
||
while (mwGrain.length < 9500 && guard++ < 500000) {
|
||
const l = rand() * 360;
|
||
const dl = Math.min(l, 360 - l);
|
||
const central = Math.exp(-((dl / 80) ** 2));
|
||
if (rand() > 0.22 + 0.78 * central) continue;
|
||
const b = gauss() * (4.5 + 2.5 * central + 3.5 * Math.exp(-((dl / 22) ** 2)));
|
||
const dust = mwDust(l, b, dl);
|
||
if (rand() > dust + 0.06) continue; // poeira esconde o grão
|
||
const boost = cloudBoost(l, b);
|
||
if (boost < 0.3 && rand() < 0.25 * central) continue;
|
||
const eq = galToEq(l, b);
|
||
mwGrain.push({ ra: eq.ra, dec: eq.dec,
|
||
alpha: 0.10 + rand() * 0.30 + 0.2 * boost,
|
||
ci: mwColor(dl) });
|
||
}
|
||
})();
|
||
|
||
/* Sprites do brilho difuso: quente / neutro / frio (escolhido por cor) */
|
||
function makeMwSprite(r, g, b) {
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = 64;
|
||
const s = c.getContext('2d');
|
||
const grad = s.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||
grad.addColorStop(0, `rgba(${r},${g},${b},1)`);
|
||
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.4)`);
|
||
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||
s.fillStyle = grad;
|
||
s.fillRect(0, 0, 64, 64);
|
||
return c;
|
||
}
|
||
const mwSprites = [makeMwSprite(255, 226, 188), // quente (núcleo)
|
||
makeMwSprite(236, 224, 214), // neutro
|
||
makeMwSprite(188, 214, 242)]; // frio (anticentro)
|
||
function spriteFor(ci) { return mwSprites[ci[2] > ci[0] ? 2 : (ci[0] > 240 ? 0 : 1)]; }
|
||
|
||
/* ── Passo de projeção: recalcula posições de tela (a cada 30s o céu gira
|
||
~0.125°, imperceptível). Camada estática (Via Láctea + estrelas fracas)
|
||
é pré-renderizada em baseCanvas; só as brilhantes cintilam por frame ── */
|
||
const baseCanvas = document.createElement('canvas');
|
||
let twinkList = [];
|
||
let lastProjMs = -Infinity;
|
||
|
||
function projectSky() {
|
||
const W = canvas.width, H = canvas.height;
|
||
if (!W || !H) return;
|
||
baseCanvas.width = W;
|
||
baseCanvas.height = H;
|
||
const bctx = baseCanvas.getContext('2d');
|
||
const horizonPx = H * HORIZON_F;
|
||
const lst = localSiderealDeg(Date.now() + window.__skyTimeOffset);
|
||
const glareOn = !document.body.classList.contains('balada') &&
|
||
!document.body.classList.contains('chuva');
|
||
twinkList = [];
|
||
|
||
bctx.globalCompositeOperation = 'lighter';
|
||
for (const p of mwGlow) {
|
||
const aa = altAz(p.ra, p.dec, lst);
|
||
const sc = skyToScreen(aa.alt, aa.az, W, horizonPx);
|
||
if (!sc) continue;
|
||
const a = p.alpha * extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn);
|
||
if (a < 0.002) continue;
|
||
bctx.globalAlpha = a;
|
||
bctx.drawImage(spriteFor(p.ci), sc.x - p.size / 2, sc.y - p.size / 2, p.size, p.size);
|
||
}
|
||
bctx.globalAlpha = 1;
|
||
for (const p of mwGrain) {
|
||
const aa = altAz(p.ra, p.dec, lst);
|
||
const sc = skyToScreen(aa.alt, aa.az, W, horizonPx);
|
||
if (!sc) continue;
|
||
const a = p.alpha * extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn);
|
||
if (a < 0.01) continue;
|
||
bctx.fillStyle = `rgba(${p.ci[0]},${p.ci[1]},${p.ci[2]},${a.toFixed(3)})`;
|
||
bctx.fillRect(sc.x - 0.5, sc.y - 0.5, 1, 1);
|
||
}
|
||
bctx.globalCompositeOperation = 'source-over';
|
||
|
||
for (let i = 0; i < STAR_DATA.length; i++) {
|
||
const [ra, dec, mag, ci] = STAR_DATA[i];
|
||
const aa = altAz(ra, dec, lst);
|
||
const sc = skyToScreen(aa.alt, aa.az, W, horizonPx);
|
||
if (!sc) continue;
|
||
const a = Math.max(0.25, Math.min(1, 1.15 - 0.15 * mag)) *
|
||
extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn);
|
||
if (a < 0.01) continue;
|
||
const r = Math.max(0.35, 2.7 - 0.42 * mag);
|
||
const c = STAR_PALETTE[ci];
|
||
if (mag <= TWINKLE_MAG) {
|
||
/* fase/freq determinísticas por índice — cintilação estável */
|
||
twinkList.push({
|
||
x: sc.x, y: sc.y, r, a,
|
||
cr: c[0], cg: c[1], cb: c[2],
|
||
phase: i * 2.3999,
|
||
freq: 0.35 + ((i * 0.61803) % 1) * 1.1,
|
||
amp: mag < 1.5 ? 0.18 : 0.45,
|
||
});
|
||
} else if (r > 0.8) {
|
||
bctx.beginPath();
|
||
bctx.arc(sc.x, sc.y, r, 0, Math.PI * 2);
|
||
bctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;
|
||
bctx.fill();
|
||
} else {
|
||
bctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;
|
||
bctx.fillRect(sc.x - r, sc.y - r, r * 2, r * 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setObserverLocation(lat, lon) {
|
||
lat = parseFloat(lat); lon = parseFloat(lon);
|
||
if (!isFinite(lat) || !isFinite(lon)) return;
|
||
if (lat === obsLat && lon === obsLon) return;
|
||
obsLat = lat;
|
||
obsLon = lon;
|
||
projectSky();
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
projectSky();
|
||
}
|
||
|
||
function drawStars(ts) {
|
||
if (ts - lastProjMs > 30000) { lastProjMs = ts; projectSky(); }
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.drawImage(baseCanvas, 0, 0);
|
||
const t = ts * 0.001;
|
||
for (const s of twinkList) {
|
||
const tw = 1 - s.amp * (0.5 + 0.5 * Math.sin(t * s.freq + s.phase));
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(${s.cr},${s.cg},${s.cb},${(s.a * tw).toFixed(3)})`;
|
||
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 */
|
||
projectSky(); // rebake do clarão solar no céu
|
||
}
|
||
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
|
||
}
|
||
projectSky(); // rebake do clarão solar no céu
|
||
}
|
||
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>
|