skeledance/plugin/contents/ui/dancer.html

2433 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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));
}
/* Matriz equatorial -> galáctica (Hipparcos, J2000) */
const E2G = [
[-0.0548755604, -0.8734370902, -0.4838350155],
[ 0.4941094279, -0.4448296300, 0.7469822445],
[-0.8676661490, -0.1980763734, 0.4559837762],
];
/* ── Via Láctea fotográfica em alta resolução ──
"Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO), reprojetada
para plate carrée por tools/build_skymap.py. A geometria é real; apenas
exposição, contraste e saturação são estilizados para o wallpaper. */
const MW_BLACK = 46;
const MW_EXPOSURE = 1.16;
const MW_SAMPLE_SCALE = 1;
const mwTex = { data: null, w: 0, h: 0 };
const mwCanvas = document.createElement('canvas');
let mwGrid = null;
const mwImg = new Image();
mwImg.onload = () => {
const c = document.createElement('canvas');
c.width = mwImg.width;
c.height = mwImg.height;
const s = c.getContext('2d', { willReadFrequently: true });
s.drawImage(mwImg, 0, 0);
mwTex.data = s.getImageData(0, 0, c.width, c.height).data;
mwTex.w = c.width;
mwTex.h = c.height;
projectSky();
};
mwImg.onerror = () => console.error('[skeledance] falha ao carregar milkyway.jpg');
mwImg.src = 'milkyway.jpg';
/* Vetores do viewport em LST=0. O passo de 30 s só aplica uma rotação e
amostra a fotografia; trigonometria e efeitos de tela ficam em cache. */
function buildMwGrid() {
const W = canvas.width, H = canvas.height;
if (!W || !H) { mwGrid = null; return; }
const horizonPx = H * HORIZON_F;
const gw = Math.ceil(W / MW_SAMPLE_SCALE);
const gh = Math.ceil(horizonPx / MW_SAMPLE_SCALE);
const n = gw * gh;
const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n);
const ext = new Float32Array(n), glare = new Float32Array(n);
const cl = Math.cos(obsLat * DEG), sl = Math.sin(obsLat * DEG);
let i = 0;
for (let gy = 0; gy < gh; gy++) {
const sy = (gy + 0.5) * MW_SAMPLE_SCALE;
const alt = (1 - sy / horizonPx) * ALT_TOP;
const ca = Math.cos(alt * DEG), sa = Math.sin(alt * DEG);
for (let gx = 0; gx < gw; gx++, i++) {
const sx = (gx + 0.5) * MW_SAMPLE_SCALE;
const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG;
const north = ca * Math.cos(az), east = ca * Math.sin(az);
wx[i] = -sl * north + cl * sa;
wy[i] = -east;
wz[i] = cl * north + sl * sa;
ext[i] = extinction(alt);
glare[i] = glareFactor(sx, sy, W, H, true);
}
}
mwCanvas.width = gw;
mwCanvas.height = gh;
mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare,
img: mwCanvas.getContext('2d').createImageData(gw, gh) };
}
function renderMilkyWay(lstDeg, glareOn) {
if (!mwGrid || !mwTex.data) return false;
const { wx, wy, wz, ext, glare, img } = mwGrid;
const cL = Math.cos(lstDeg * DEG), sL = Math.sin(lstDeg * DEG);
const m00 = E2G[0][0] * cL + E2G[0][1] * sL;
const m01 = E2G[0][0] * sL - E2G[0][1] * cL;
const m02 = E2G[0][2];
const m10 = E2G[1][0] * cL + E2G[1][1] * sL;
const m11 = E2G[1][0] * sL - E2G[1][1] * cL;
const m12 = E2G[1][2];
const m20 = E2G[2][0] * cL + E2G[2][1] * sL;
const m21 = E2G[2][0] * sL - E2G[2][1] * cL;
const m22 = E2G[2][2];
const tw = mwTex.w, th = mwTex.h, tex = mwTex.data, px = img.data;
const inv2pi = 1 / (2 * Math.PI), invpi = 1 / Math.PI;
for (let i = 0, j = 0; i < wx.length; i++, j += 4) {
const gx = m00 * wx[i] + m01 * wy[i] + m02 * wz[i];
const gy = m10 * wx[i] + m11 * wy[i] + m12 * wz[i];
const gz = m20 * wx[i] + m21 * wy[i] + m22 * wz[i];
const l = Math.atan2(gy, gx);
const b = Math.asin(Math.max(-1, Math.min(1, gz)));
const absB = Math.abs(b / DEG);
if (absB >= 42) { px[j + 3] = 0; continue; }
let tx = ((Math.PI - l) * inv2pi * tw) | 0;
let ty = ((Math.PI / 2 - b) * invpi * th) | 0;
if (tx >= tw) tx = tw - 1;
if (ty >= th) ty = th - 1;
const k = (ty * tw + tx) * 4;
const sr = tex[k], sg = tex[k + 1], sb = tex[k + 2];
const lum = sr * 0.2126 + sg * 0.7152 + sb * 0.0722;
let signal = (lum - MW_BLACK) / (255 - MW_BLACK);
if (signal <= 0) { px[j + 3] = 0; continue; }
signal = Math.pow(Math.min(1, signal), 0.72);
let mask = 1;
if (absB > 18) {
mask = (42 - absB) / 24;
mask = mask * mask * (3 - 2 * mask);
}
const alpha = Math.min(1, signal * MW_EXPOSURE) * ext[i] *
(glareOn ? glare[i] : 1) * mask;
if (alpha < 0.004) { px[j + 3] = 0; continue; }
/* Revelação digital: preto profundo, saturação cromática e sombras frias. */
let r = Math.max(0, sr - 34) * 1.58;
let g = Math.max(0, sg - 34) * 1.58;
let bl = Math.max(0, sb - 34) * 1.58;
const outLum = r * 0.2126 + g * 0.7152 + bl * 0.0722;
r = outLum + (r - outLum) * 1.48 + 10 * (1 - signal) + 16 * signal;
g = outLum + (g - outLum) * 1.48;
bl = outLum + (bl - outLum) * 1.48 + 24 * (1 - signal);
px[j] = Math.max(0, Math.min(255, r));
px[j + 1] = Math.max(0, Math.min(255, g));
px[j + 2] = Math.max(0, Math.min(255, bl));
px[j + 3] = alpha * 255;
}
mwCanvas.getContext('2d').putImageData(img, 0, 0);
return true;
}
/* ── 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 = [];
if (renderMilkyWay(lst, glareOn)) {
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.save();
bctx.globalCompositeOperation = 'lighter';
bctx.globalAlpha = 0.22;
bctx.filter = `blur(${Math.max(7, H * 0.009)}px) saturate(150%)`;
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.globalAlpha = 0.94;
bctx.filter = 'contrast(116%) saturate(158%)';
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.restore();
}
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;
buildMwGrid();
projectSky();
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
buildMwGrid();
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 (0100 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>