skeledance/plugin/contents/ui/dancer.html
ltadeu6 22d9f9672b
Improve floor grid and add dev tooling
- Scale floor perspective with viewport height so grid reaches horizon
  on full-screen KDE (was broken with fixed 700px perspective value)
- Increase grid line thickness 1px → 2px to reduce sub-pixel flickering
- Remove DANCEBOT-9000 title text
- Add viewport meta tag for correct layout in WebEngineView
- Add dev-server.sh (port 3000) with in-browser music ticker shim

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 07:42:19 -03:00

1057 lines
29 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-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
============================================================ */
.floor-wrap {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 42%;
overflow: hidden;
pointer-events: none;
}
.floor {
position: absolute;
bottom: 0;
left: -100%; right: -100%;
height: 400%;
transform: perspective(700px) rotateX(55deg);
transform-origin: bottom center;
background-image:
repeating-linear-gradient(
90deg,
transparent 0,
transparent calc(5% - 2px),
rgba(0, 200, 255, 0.3) calc(5% - 2px),
rgba(0, 200, 255, 0.3) 5%
),
repeating-linear-gradient(
0deg,
transparent 0,
transparent 48px,
rgba(0, 200, 255, 0.2) 48px,
rgba(0, 200, 255, 0.2) 50px
);
animation: gridScroll 0.833s linear infinite;
}
@keyframes gridScroll {
from { background-position: 0 0, 0 0; }
to { background-position: 0 0, 0 50px; }
}
/* 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; }
}
/* ============================================================
VU METER (moved up)
============================================================ */
.vu-meter {
position: absolute;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: 4px;
pointer-events: none;
}
.vu-bar {
width: 9px;
min-height: 3px;
border-radius: 3px 3px 0 0;
}
/* ============================================================
MUSIC TICKER
============================================================ */
.music-ticker {
position: absolute;
bottom: 4%;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
pointer-events: none;
max-width: 80vw;
overflow: hidden;
}
.music-icon {
color: #ff44aa;
font-size: 18px;
text-shadow: 0 0 10px #ff44aa;
animation: iconPulse 0.5s 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-label {
color: rgba(0, 200, 255, 0.6);
font-family: 'Courier New', monospace;
font-size: clamp(9px, 1vw, 13px);
letter-spacing: 3px;
flex-shrink: 0;
}
.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; }
@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; }
}
/* ============================================================
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: 40%;
transform: translateX(-50%);
filter:
drop-shadow(0 0 35px rgba(0,200,255,0.25))
drop-shadow(0 20px 15px rgba(0,0,80,0.6));
}
/* 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); }
}
/* ROBOT SHADOW (ellipse on the floor) */
.robot-shadow {
position: absolute;
bottom: -28px;
left: 50%;
transform: translateX(-50%);
width: 130px; height: 22px;
background: radial-gradient(ellipse, rgba(0,100,255,0.38) 0%, transparent 68%);
border-radius: 50%;
animation: shadowAnim 0.5s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes shadowAnim {
from { transform: translateX(-50%) scaleX(0.75) scaleY(0.6); opacity: 0.35; }
to { transform: translateX(-50%) scaleX(1.3) scaleY(1.6); opacity: 0.85; }
}
</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>
<!-- Horizon glow -->
<div class="horizon"></div>
<!-- Retrowave grid floor -->
<div class="floor-wrap">
<div class="floor"></div>
</div>
<!-- ===================== ROBOT ===================== -->
<div class="robot-wrap">
<div class="robot">
<!-- Antenna -->
<div class="antenna-wrap">
<div class="antenna-stick">
<div class="antenna-ball"></div>
</div>
</div>
<!-- Head -->
<div class="head">
<div class="eyes">
<div class="eye"></div>
<div class="eye"></div>
</div>
<div class="mouth-panel">
<div class="mouth-seg"></div>
<div class="mouth-seg"></div>
<div class="mouth-seg"></div>
<div class="mouth-seg"></div>
<div class="mouth-seg"></div>
</div>
</div>
<!-- Neck -->
<div class="neck"></div>
<!-- Middle row: left arm, torso, right arm -->
<div class="middle">
<div class="shoulder"></div>
<div class="arm left">
<div class="hand"></div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;">
<div class="torso">
<div class="chest-screen" id="chestScreen">
<div class="eq-row" id="eqTop"></div>
<div class="chest-logo">♪ ♫ ♪</div>
<div class="eq-row" id="eqBot"></div>
</div>
</div>
<div class="hip">
<div class="rivet"></div>
<div class="rivet"></div>
<div class="rivet"></div>
</div>
</div>
<div class="arm right">
<div class="hand"></div>
</div>
<div class="shoulder"></div>
</div>
<!-- Legs -->
<div class="legs">
<div class="leg-grp">
<div class="upper-leg left"></div>
<div class="knee"></div>
<div class="lower-leg left">
<div class="foot"></div>
</div>
</div>
<div class="leg-grp">
<div class="upper-leg right"></div>
<div class="knee"></div>
<div class="lower-leg right">
<div class="foot"></div>
</div>
</div>
</div>
</div><!-- .robot -->
<div class="robot-shadow"></div>
</div><!-- .robot-wrap -->
<!-- VU Meter -->
<div class="vu-meter" id="vuMeter"></div>
<!-- Music ticker -->
<div class="music-ticker">
<span class="music-icon"></span>
<span class="music-label">NOW PLAYING</span>
<div class="music-scroll-wrap">
<span class="music-text short" id="musicText">— —</span>
</div>
</div>
<!-- Scanlines overlay -->
<div class="scanlines"></div>
</div><!-- .scene -->
<script>
/* ============================================================
CONSTANTS
120 BPM → beat = 500ms. All timings derived from this.
============================================================ */
const BPM = 120;
const BEAT_MS = 60000 / BPM; // 500ms
/* ============================================================
STARFIELD
============================================================ */
const canvas = document.getElementById('starfield');
const ctx = canvas.getContext('2d');
const STAR_COUNT = 220;
const stars = [];
function initStars() {
stars.length = 0;
for (let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height * 0.62,
r: Math.random() * 1.6 + 0.2,
phase: Math.random() * Math.PI * 2,
freq: 0.4 + Math.random() * 1.2,
color: Math.random() < 0.15 ? '#ffddaa' : (Math.random() < 0.5 ? '#ccd8ff' : '#eeeeff'),
});
}
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initStars();
}
function drawStars(ts) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const t = ts * 0.001;
stars.forEach(s => {
const alpha = 0.25 + 0.75 * (0.5 + 0.5 * Math.sin(t * s.freq + s.phase));
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = s.color.replace(')', `, ${alpha.toFixed(2)})`)
.replace('rgb', 'rgba').replace('#ccd8ff', `rgba(204,216,255,${alpha.toFixed(2)})`)
.replace('#eeeeff', `rgba(238,238,255,${alpha.toFixed(2)})`)
.replace('#ffddaa', `rgba(255,221,170,${alpha.toFixed(2)})`);
ctx.fill();
});
requestAnimationFrame(drawStars);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
requestAnimationFrame(drawStars);
const floorEl = document.querySelector('.floor');
function updateFloorPerspective() {
const p = Math.round(window.innerHeight * 1.05);
floorEl.style.transform = `perspective(${p}px) rotateX(55deg)`;
}
window.addEventListener('resize', updateFloorPerspective);
updateFloorPerspective();
/* ============================================================
CHEST EQ BARS (JS-animated for smooth randomness)
============================================================ */
const EQ_COLS = 7;
const EQ_COLORS_T = ['#00ffcc','#00eebb','#00ddff','#0099ff','#0055ff','#00ddff','#00ffcc'];
const EQ_COLORS_B = ['#ff44aa','#ff55bb','#ff66cc','#ff44aa','#ff66cc','#ff55bb','#ff44aa'];
function buildEqRow(container, colors) {
const cols = [];
for (let i = 0; i < EQ_COLS; i++) {
const d = document.createElement('div');
d.className = 'eq-col';
d.style.background = colors[i];
d.style.height = '50%';
d._phase = Math.random() * Math.PI * 2;
d._speed = 3 + Math.random() * 5;
container.appendChild(d);
cols.push(d);
}
return cols;
}
const eqTopCols = buildEqRow(document.getElementById('eqTop'), EQ_COLORS_T);
const eqBotCols = buildEqRow(document.getElementById('eqBot'), EQ_COLORS_B);
function animateEQ(ts) {
const t = ts * 0.001;
[...eqTopCols, ...eqBotCols].forEach(col => {
const pct = 15 + 85 * (0.5 + 0.5 * Math.sin(t * col._speed + col._phase));
col.style.height = pct + '%';
col.style.opacity = 0.5 + 0.5 * (pct / 100);
});
requestAnimationFrame(animateEQ);
}
requestAnimationFrame(animateEQ);
/* ============================================================
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',
];
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._maxH = 15 + Math.random() * 55;
bar._phase = Math.random() * Math.PI * 2;
bar._speed = 1.5 + Math.random() * 4;
vuMeter.appendChild(bar);
vuBars.push(bar);
}
function animateVU(ts) {
const t = ts * 0.001;
vuBars.forEach(bar => {
const h = bar._maxH * Math.abs(Math.sin(t * bar._speed + bar._phase));
bar.style.height = Math.max(3, h) + 'px';
bar.style.opacity = 0.55 + 0.45 * (h / bar._maxH);
});
requestAnimationFrame(animateVU);
}
requestAnimationFrame(animateVU);
/* ============================================================
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);
}
/* Spawn on every beat (500ms) with a 50 % chance of two notes */
setInterval(() => {
spawnNote();
if (Math.random() < 0.5) spawnNote();
}, BEAT_MS);
/* First note immediately */
spawnNote();
/* ============================================================
MUSIC INFO (called by QML via runJavaScript)
============================================================ */
function updateMusic(artist, title) {
const el = document.getElementById('musicText');
const text = (artist && title) ? `${artist}${title}`
: (title || artist || '— —');
if (el.textContent === text) return;
el.textContent = text;
// scroll only if text is long
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);
/* ============================================================
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>