mirror of
https://github.com/deadcxap/bs-overlay.git
synced 2026-07-02 05:43:39 +03:00
744 lines
29 KiB
HTML
744 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>BS+ Cyberpunk Overlay</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap');
|
|
|
|
:root {
|
|
--neon-cyan: #0ff;
|
|
--neon-magenta: #f0f;
|
|
--neon-lime: #39ff14;
|
|
--neon-red: #ff3b3b;
|
|
--bg-glass: rgba(10, 10, 15, 0.65);
|
|
--border-glow: 0 0 10px rgba(0, 255, 255, 0.3);
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
background: transparent;
|
|
font-family: 'Orbitron', sans-serif;
|
|
color: #fff;
|
|
overflow: hidden;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* === АНИМАЦИИ === */
|
|
@keyframes rainbow {
|
|
0% { filter: hue-rotate(0deg); }
|
|
100% { filter: hue-rotate(360deg); }
|
|
}
|
|
|
|
/* === ОБЩИЕ КОНТЕЙНЕРЫ === */
|
|
#app-container {
|
|
position: absolute;
|
|
transition: transform 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
width: max-content;
|
|
max-width: 90vw;
|
|
}
|
|
|
|
.view-mode {
|
|
position: absolute;
|
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
width: max-content;
|
|
}
|
|
|
|
.view-mode.active {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
|
|
/* === PLAYING MODE (GLASS PANEL) === */
|
|
.glass-panel {
|
|
background: var(--bg-glass);
|
|
backdrop-filter: blur(6px);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
box-shadow: var(--border-glow);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
#header-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
#cover {
|
|
width: 90px;
|
|
height: 90px;
|
|
border-radius: 10px;
|
|
object-fit: cover;
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
box-shadow: 0 0 10px var(--neon-cyan);
|
|
animation: rainbow 10s linear infinite;
|
|
}
|
|
|
|
#text-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
width: 320px;
|
|
}
|
|
|
|
#title {
|
|
font-size: 22px;
|
|
font-weight: 900;
|
|
background: linear-gradient(45deg, var(--neon-cyan), #fff);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
line-height: 1.2;
|
|
filter: drop-shadow(0 0 5px rgba(0,255,255,0.4));
|
|
}
|
|
|
|
#artist-mapper {
|
|
font-size: 14px;
|
|
color: #ccc;
|
|
letter-spacing: 0.5px;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
#meta-line {
|
|
font-size: 13px;
|
|
color: #999;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
#meta-line span { margin-right: 12px; }
|
|
#difficulty { color: var(--neon-lime); text-shadow: 0 0 5px rgba(57, 255, 20, 0.5); }
|
|
|
|
/* === СТАТИСТИКА (Внутри панели) === */
|
|
#stats-row {
|
|
display: flex;
|
|
padding-top: 5px;
|
|
border-top: 1px dashed rgba(255,255,255,0.1);
|
|
margin-top: 5px;
|
|
}
|
|
|
|
/* === HP PROGRESS BAR === */
|
|
#hp-bar-wrapper {
|
|
position: relative;
|
|
width: 200px;
|
|
height: 18px;
|
|
background: rgba(0,0,0,0.6);
|
|
border-radius: 4px;
|
|
border: 1px solid rgba(255, 0, 255, 0.4);
|
|
box-shadow: 0 0 8px rgba(255, 0, 255, 0.3);
|
|
overflow: hidden;
|
|
display: flex;
|
|
}
|
|
|
|
#hp-bar-fill {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #8b0000, var(--neon-magenta));
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
#hp-val {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
color: #fff;
|
|
text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* === PROGRESS BAR === */
|
|
#progress-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 12px;
|
|
background: rgba(0,0,0,0.6);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
|
|
display: flex;
|
|
}
|
|
|
|
#progress-fill {
|
|
width: 0%;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--neon-magenta), var(--neon-cyan));
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
#time-prog {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* === НИЖНИЕ СТАТЫ (Без фона) === */
|
|
#bottom-stats {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 5px;
|
|
width: 100%;
|
|
}
|
|
|
|
.bottom-stat-row {
|
|
display: flex;
|
|
gap: 20px;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat-item .label {
|
|
font-size: 11px;
|
|
color: #999;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.stat-item .val {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
text-shadow: 1px 1px 3px #000;
|
|
}
|
|
|
|
#combo-val { color: var(--neon-cyan); text-shadow: 0 0 8px rgba(0, 255, 255, 0.6), 1px 1px 2px #000; }
|
|
#miss-val { color: var(--neon-red); text-shadow: 0 0 8px rgba(255, 59, 59, 0.6), 1px 1px 2px #000; }
|
|
|
|
.stat-item.acc-large .label { font-size: 14px; }
|
|
.stat-item.acc-large #acc-num { font-size: 32px; color: #fff; }
|
|
.stat-item.acc-large #acc-grade { font-size: 32px; }
|
|
|
|
/* === КОМПАКТНЫЙ BEATLEADER === */
|
|
#bl-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
#bl-avatar {
|
|
width: 75px;
|
|
height: 75px;
|
|
border-radius: 12px;
|
|
border: 2px solid rgba(255,255,255,0.2);
|
|
box-shadow: 0 0 10px var(--neon-cyan);
|
|
object-fit: cover;
|
|
display: none;
|
|
animation: rainbow 10s linear infinite;
|
|
}
|
|
|
|
#bl-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 3px;
|
|
}
|
|
|
|
#bl-name {
|
|
font-size: 18px;
|
|
font-weight: 900;
|
|
color: var(--neon-magenta);
|
|
text-shadow: 0 0 5px rgba(255,0,255,0.5);
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.bl-stat-compact {
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
|
|
.bl-stat-compact span {
|
|
color: var(--neon-cyan);
|
|
font-weight: bold;
|
|
text-shadow: 0 0 5px rgba(0,255,255,0.5);
|
|
}
|
|
|
|
/* === SETTINGS MODAL (F1) === */
|
|
#settings-modal {
|
|
position: fixed;
|
|
top: 50%; left: 50%;
|
|
transform: translate(-50%, -50%) scale(0.9);
|
|
background: rgba(10, 10, 15, 0.95);
|
|
border: 1px solid var(--neon-cyan);
|
|
box-shadow: 0 0 30px rgba(0, 255, 255, 0.3);
|
|
padding: 25px;
|
|
border-radius: 12px;
|
|
z-index: 999;
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
width: 320px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
}
|
|
#settings-modal.show { display: flex; opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
|
#settings-modal h2 { margin: 0; color: var(--neon-cyan); text-align: center; font-size: 18px; }
|
|
.setting-row { display: flex; flex-direction: column; gap: 5px; }
|
|
.setting-row.checkbox-row { flex-direction: row; align-items: center; gap: 10px; cursor: pointer; }
|
|
.setting-row input[type="text"], .setting-row input[type="number"] {
|
|
background: rgba(0,0,0,0.5); color: #fff; border: 1px solid #444;
|
|
padding: 8px; font-family: 'Orbitron', sans-serif; border-radius: 4px; outline: none;
|
|
}
|
|
.setting-row input:focus { border-color: var(--neon-cyan); }
|
|
|
|
/* Сетка для Radio Button'ов (вместо Select) */
|
|
.radio-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
.radio-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: rgba(0,0,0,0.5);
|
|
padding: 6px 8px;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: 0.2s;
|
|
}
|
|
.radio-label:hover { border-color: var(--neon-cyan); }
|
|
.radio-label input { margin: 0; cursor: pointer; accent-color: var(--neon-cyan); }
|
|
|
|
.setting-row button {
|
|
background: transparent; color: var(--neon-cyan); border: 1px solid var(--neon-cyan); padding: 10px;
|
|
font-weight: bold; cursor: pointer; border-radius: 4px; margin-top: 5px;
|
|
font-family: 'Orbitron', sans-serif; transition: 0.2s;
|
|
}
|
|
.setting-row button:hover { background: var(--neon-cyan); color: #000; box-shadow: 0 0 15px var(--neon-cyan); }
|
|
|
|
#debug {
|
|
position: fixed; bottom: 10px; left: 10px; background: rgba(0,0,0,0.8);
|
|
color: var(--neon-lime); padding: 4px 8px; font-size: 11px;
|
|
border-radius: 4px; opacity: 0; transition: opacity 0.5s; z-index: 1000;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="app-container">
|
|
<div id="menu-overlay" class="view-mode glass-panel">
|
|
<div id="bl-wrapper">
|
|
<img id="bl-avatar" src="" alt="Avatar">
|
|
<div id="bl-info">
|
|
<div id="bl-name">Loading...</div>
|
|
<div class="bl-stat-compact">Global: <span id="bl-global">#--</span></div>
|
|
<div class="bl-stat-compact">Local: <span id="bl-local">#--</span></div>
|
|
<div class="bl-stat-compact">PP: <span id="bl-pp">-- pp</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="playing-overlay" class="view-mode">
|
|
<div class="glass-panel">
|
|
<div id="header-row">
|
|
<img id="cover" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" alt="Cover">
|
|
<div id="text-block">
|
|
<div id="title">Waiting for song...</div>
|
|
<div id="artist-mapper">-</div>
|
|
<div id="meta-line">
|
|
<span id="difficulty">-</span>
|
|
<span id="bpm">BPM -</span>
|
|
<span id="key">BSR: -</span>
|
|
</div>
|
|
|
|
<div id="stats-row">
|
|
<div id="hp-bar-wrapper">
|
|
<div id="hp-bar-fill"></div>
|
|
<div id="hp-val">100%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="progress-wrapper">
|
|
<div id="progress-fill"></div>
|
|
<div id="time-prog">0:00 / 0:00</div>
|
|
</div>
|
|
|
|
<div id="bottom-stats">
|
|
<div class="bottom-stat-row">
|
|
<div class="stat-item"><span class="label">Miss</span><span class="val" id="miss-val">0</span></div>
|
|
<div class="stat-item"><span class="label">Combo</span><span class="val" id="combo-val">0</span></div>
|
|
</div>
|
|
<div class="stat-item acc-large">
|
|
<span class="label">Acc</span>
|
|
<span class="val" id="acc-num">0.0%</span>
|
|
<span class="val" id="acc-grade" style="color:var(--neon-cyan); text-shadow: 0 0 10px var(--neon-cyan)">SS</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="settings-modal">
|
|
<h2>SYSTEM SETUP</h2>
|
|
<div class="setting-row">
|
|
<label>WebSocket URL</label>
|
|
<input type="text" id="inp-ws" placeholder="ws://127.0.0.1:2947/socket">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Layout (Выравнивание)</label>
|
|
<div class="radio-grid">
|
|
<label class="radio-label"><input type="radio" name="layout" value="top-left"> Top Left</label>
|
|
<label class="radio-label"><input type="radio" name="layout" value="top-right"> Top Right</label>
|
|
<label class="radio-label"><input type="radio" name="layout" value="bottom-left"> Bottom Left</label>
|
|
<label class="radio-label"><input type="radio" name="layout" value="bottom-right"> Bottom Right</label>
|
|
</div>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Scale (0.5 - 2.0)</label>
|
|
<input type="number" step="0.1" id="inp-scale">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>BeatLeader ID / Nickname</label>
|
|
<input type="text" id="inp-bl" placeholder="Например: 76561198029377687">
|
|
</div>
|
|
<label class="setting-row checkbox-row">
|
|
<input type="checkbox" id="inp-show-bl">
|
|
Показывать BeatLeader в меню
|
|
</label>
|
|
<div class="setting-row">
|
|
<button onclick="saveSettings()">APPLY & RECONNECT</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="debug">System init...</div>
|
|
|
|
<script>
|
|
const els = {
|
|
app: document.getElementById('app-container'),
|
|
menuOverlay: document.getElementById('menu-overlay'),
|
|
playingOverlay: document.getElementById('playing-overlay'),
|
|
progFill: document.getElementById('progress-fill'),
|
|
time: document.getElementById('time-prog'),
|
|
title: document.getElementById('title'),
|
|
artist: document.getElementById('artist-mapper'),
|
|
diff: document.getElementById('difficulty'),
|
|
bpm: document.getElementById('bpm'),
|
|
key: document.getElementById('key'),
|
|
cover: document.getElementById('cover'),
|
|
accNum: document.getElementById('acc-num'),
|
|
accGrade: document.getElementById('acc-grade'),
|
|
combo: document.getElementById('combo-val'),
|
|
miss: document.getElementById('miss-val'),
|
|
hpVal: document.getElementById('hp-val'),
|
|
hpFill: document.getElementById('hp-bar-fill'),
|
|
debug: document.getElementById('debug'),
|
|
settings: document.getElementById('settings-modal'),
|
|
blAvatar: document.getElementById('bl-avatar'),
|
|
blName: document.getElementById('bl-name'),
|
|
blGlobal: document.getElementById('bl-global'),
|
|
blLocal: document.getElementById('bl-local'),
|
|
blPp: document.getElementById('bl-pp')
|
|
};
|
|
|
|
let config = { ws: 'ws://127.0.0.1:2947/socket', layout: 'top-left', scale: 1.0, blId: '', showBL: true };
|
|
let ws = null;
|
|
let reconnectAttempts = 0;
|
|
let reconnectTimeout = null;
|
|
let debugTimeout = null;
|
|
let duration = 0;
|
|
let lastBlFetch = 0;
|
|
|
|
function init() {
|
|
loadSettings();
|
|
applyLayout();
|
|
connectWS();
|
|
setMode('menu');
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'F1') els.settings.classList.toggle('show');
|
|
});
|
|
}
|
|
|
|
function loadSettings() {
|
|
const saved = localStorage.getItem('bsCyberConfig');
|
|
if (saved) config = { ...config, ...JSON.parse(saved) };
|
|
|
|
document.getElementById('inp-ws').value = config.ws;
|
|
document.getElementById('inp-scale').value = config.scale;
|
|
document.getElementById('inp-bl').value = config.blId;
|
|
document.getElementById('inp-show-bl').checked = config.showBL !== false;
|
|
|
|
// Устанавливаем радио-кнопку
|
|
const radio = document.querySelector(`input[name="layout"][value="${config.layout}"]`);
|
|
if(radio) radio.checked = true;
|
|
}
|
|
|
|
function saveSettings() {
|
|
config.ws = document.getElementById('inp-ws').value;
|
|
config.scale = parseFloat(document.getElementById('inp-scale').value) || 1.0;
|
|
config.blId = document.getElementById('inp-bl').value.trim();
|
|
config.showBL = document.getElementById('inp-show-bl').checked;
|
|
|
|
// Читаем радио-кнопку
|
|
const checkedRadio = document.querySelector('input[name="layout"]:checked');
|
|
if(checkedRadio) config.layout = checkedRadio.value;
|
|
|
|
localStorage.setItem('bsCyberConfig', JSON.stringify(config));
|
|
els.settings.classList.remove('show');
|
|
|
|
applyLayout();
|
|
connectWS();
|
|
setMode('menu');
|
|
}
|
|
|
|
function applyLayout() {
|
|
const isLeft = config.layout.includes('left');
|
|
const isTop = config.layout.includes('top');
|
|
|
|
// 1. Позиция на экране и точка масштабирования
|
|
els.app.style.transformOrigin = `${isLeft ? 'left' : 'right'} ${isTop ? 'top' : 'bottom'}`;
|
|
els.app.style.transform = `scale(${config.scale})`;
|
|
|
|
els.app.style.top = isTop ? '20px' : 'auto';
|
|
els.app.style.bottom = isTop ? 'auto' : '20px';
|
|
els.app.style.left = isLeft ? '20px' : 'auto';
|
|
els.app.style.right = isLeft ? 'auto' : '20px';
|
|
|
|
// 2. ИНВЕРСИЯ ВЕРТИКАЛИ ДЛЯ BOTTOM:
|
|
// Если режим bottom, переворачиваем flex-поток, чтобы стеклянная панель была у края экрана
|
|
els.playingOverlay.style.flexDirection = isTop ? 'column' : 'column-reverse';
|
|
|
|
els.menuOverlay.style.top = isTop ? '0' : 'auto';
|
|
els.menuOverlay.style.bottom = isTop ? 'auto' : '0';
|
|
els.playingOverlay.style.top = isTop ? '0' : 'auto';
|
|
els.playingOverlay.style.bottom = isTop ? 'auto' : '0';
|
|
|
|
// 3. Отзеркаливание контейнеров (Left / Right)
|
|
els.app.style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
els.playingOverlay.style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
|
|
// 4. Отзеркаливание контента внутри карточки
|
|
document.getElementById('header-row').style.flexDirection = isLeft ? 'row' : 'row-reverse';
|
|
document.getElementById('text-block').style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
document.getElementById('text-block').style.textAlign = isLeft ? 'left' : 'right';
|
|
document.getElementById('stats-row').style.justifyContent = isLeft ? 'flex-start' : 'flex-end';
|
|
|
|
// 5. Отзеркаливание нижних статов
|
|
document.getElementById('bottom-stats').style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
document.querySelector('.bottom-stat-row').style.flexDirection = isLeft ? 'row' : 'row-reverse';
|
|
|
|
// 6. Отзеркаливание плашки BeatLeader
|
|
document.getElementById('bl-wrapper').style.flexDirection = isLeft ? 'row' : 'row-reverse';
|
|
document.getElementById('bl-info').style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
document.getElementById('bl-info').style.textAlign = isLeft ? 'left' : 'right';
|
|
|
|
// 7. Направление заполнения прогресс-баров
|
|
els.hpFill.style.marginLeft = isLeft ? '0' : 'auto';
|
|
els.progFill.style.marginLeft = isLeft ? '0' : 'auto';
|
|
}
|
|
|
|
function showDebug(msg) {
|
|
clearTimeout(debugTimeout);
|
|
els.debug.textContent = msg;
|
|
els.debug.style.opacity = 1;
|
|
console.log("[BS+ Overlay]", msg);
|
|
debugTimeout = setTimeout(() => els.debug.style.opacity = 0, 5000);
|
|
}
|
|
|
|
function setMode(mode) {
|
|
if (mode === 'playing') {
|
|
els.menuOverlay.classList.remove('active');
|
|
els.playingOverlay.classList.add('active');
|
|
} else {
|
|
els.playingOverlay.classList.remove('active');
|
|
if (config.showBL) {
|
|
els.menuOverlay.classList.add('active');
|
|
fetchBL();
|
|
} else {
|
|
els.menuOverlay.classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
|
|
function getGrade(acc) {
|
|
if (acc >= 0.9) return { grade: 'SS', color: 'var(--neon-cyan)' };
|
|
if (acc >= 0.8) return { grade: 'S', color: '#fff' };
|
|
if (acc >= 0.65) return { grade: 'A', color: 'var(--neon-lime)' };
|
|
if (acc >= 0.5) return { grade: 'B', color: '#ffeb3b' };
|
|
if (acc >= 0.35) return { grade: 'C', color: '#ff9800' };
|
|
if (acc >= 0.2) return { grade: 'D', color: 'var(--neon-red)' };
|
|
return { grade: 'E', color: 'darkred' };
|
|
}
|
|
|
|
async function fetchBSR(hash) {
|
|
try {
|
|
els.key.textContent = `BSR: Loading...`;
|
|
const res = await fetch(`https://api.beatsaver.com/maps/hash/${hash}`);
|
|
if (!res.ok) throw new Error("Not found");
|
|
const data = await res.json();
|
|
els.key.textContent = `BSR: ${data.id}`;
|
|
} catch (err) {
|
|
els.key.textContent = `BSR: N/A`;
|
|
}
|
|
}
|
|
|
|
async function fetchBL(force = false) {
|
|
if (!config.blId || !config.showBL) return;
|
|
if (!force && Date.now() - lastBlFetch < 60000) return;
|
|
|
|
try {
|
|
showDebug("Fetching BeatLeader data...");
|
|
const isNumeric = /^\d+$/.test(config.blId);
|
|
|
|
let originalUrl;
|
|
if (isNumeric) {
|
|
originalUrl = `https://api.beatleader.xyz/player/${config.blId}?stats=true`;
|
|
} else {
|
|
originalUrl = `https://api.beatleader.xyz/players?search=${encodeURIComponent(config.blId)}`;
|
|
}
|
|
|
|
const proxiedUrl = `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(originalUrl)}`;
|
|
const res = await fetch(proxiedUrl, { headers: { 'Accept': 'application/json' } });
|
|
|
|
if (!res.ok) throw new Error(`Network error: ${res.status}`);
|
|
|
|
const json = await res.json();
|
|
const player = json.data ? json.data[0] : json;
|
|
|
|
if (!player || !player.name) throw new Error("Player not found");
|
|
|
|
els.blName.textContent = player.name || "Unknown";
|
|
els.blGlobal.textContent = player.rank ? `#${player.rank.toLocaleString()}` : "#--";
|
|
els.blLocal.textContent = player.countryRank ? `#${player.countryRank.toLocaleString()} (${player.country || 'N/A'})` : "#-- (N/A)";
|
|
els.blPp.textContent = player.pp ? `${Math.round(player.pp).toLocaleString()}` : "--";
|
|
|
|
if (player.avatar) {
|
|
els.blAvatar.src = player.avatar;
|
|
els.blAvatar.style.display = 'block';
|
|
} else {
|
|
els.blAvatar.style.display = 'none';
|
|
}
|
|
|
|
lastBlFetch = Date.now();
|
|
showDebug("BeatLeader updated ✓");
|
|
} catch (err) {
|
|
showDebug("BL Error: " + err.message);
|
|
els.blName.textContent = "Error loading profile";
|
|
console.error("BeatLeader fetch failed:", err);
|
|
}
|
|
}
|
|
|
|
function connectWS() {
|
|
if (ws) { ws.onclose = null; ws.close(); }
|
|
clearTimeout(reconnectTimeout);
|
|
|
|
showDebug(`Connecting to ${config.ws}...`);
|
|
try { ws = new WebSocket(config.ws); } catch (e) { handleDisconnect(); return; }
|
|
|
|
ws.onopen = () => { showDebug('✅ WebSocket Connected'); reconnectAttempts = 0; };
|
|
ws.onclose = () => handleDisconnect();
|
|
ws.onerror = () => handleDisconnect();
|
|
|
|
ws.onmessage = (ev) => {
|
|
const data = JSON.parse(ev.data);
|
|
const event = data._event;
|
|
|
|
if (event === 'gameState') {
|
|
if (data.gameStateChanged === 'Playing') setMode('playing');
|
|
else setMode('menu');
|
|
}
|
|
|
|
if (event === 'mapInfo' && data.mapInfoChanged) {
|
|
const m = data.mapInfoChanged;
|
|
els.title.textContent = m.sub_name ? `${m.name} ${m.sub_name}` : m.name;
|
|
|
|
const mapper = (m.mapper && m.mapper !== 'Not mapped') ? m.mapper : '';
|
|
els.artist.textContent = mapper ? `${m.artist} // ${mapper}` : m.artist;
|
|
|
|
els.diff.textContent = `${m.characteristic} ${m.difficulty}`;
|
|
els.bpm.textContent = `BPM ${Math.round(m.BPM || 0)}`;
|
|
if (m.coverRaw) els.cover.src = `data:image/png;base64,${m.coverRaw}`;
|
|
|
|
duration = (m.duration || 0) / 1000;
|
|
|
|
if (m.BSRKey) {
|
|
els.key.textContent = `BSR: ${m.BSRKey}`;
|
|
} else if (m.level_id?.startsWith('custom_level_')) {
|
|
fetchBSR(m.level_id.substring(13));
|
|
} else {
|
|
els.key.textContent = `OST/DLC`;
|
|
}
|
|
}
|
|
|
|
if (event === 'score' && data.scoreEvent) {
|
|
const s = data.scoreEvent;
|
|
|
|
if (s.accuracy !== undefined) {
|
|
const accRaw = s.accuracy;
|
|
const accObj = getGrade(accRaw);
|
|
els.accNum.textContent = `${(accRaw * 100).toFixed(1)}%`;
|
|
els.accGrade.textContent = accObj.grade;
|
|
els.accGrade.style.color = accObj.color;
|
|
els.accGrade.style.textShadow = `0 0 10px ${accObj.color}, 1px 1px 3px #000`;
|
|
}
|
|
|
|
if (s.combo !== undefined) els.combo.textContent = s.combo;
|
|
if (s.missCount !== undefined) els.miss.textContent = s.missCount;
|
|
|
|
if (s.currentHealth !== undefined) {
|
|
const hpPct = Math.round(s.currentHealth * 100);
|
|
els.hpVal.textContent = `${hpPct}%`;
|
|
els.hpFill.style.width = `${hpPct}%`;
|
|
}
|
|
|
|
if (duration > 0 && s.time !== undefined) {
|
|
const pct = Math.min((s.time / duration) * 100, 100);
|
|
els.progFill.style.width = `${pct}%`;
|
|
|
|
const curM = Math.floor(s.time / 60);
|
|
const curS = Math.floor(s.time % 60).toString().padStart(2, '0');
|
|
const totM = Math.floor(duration / 60);
|
|
const totS = Math.floor(duration % 60).toString().padStart(2, '0');
|
|
els.time.textContent = `${curM}:${curS} / ${totM}:${totS}`;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleDisconnect() {
|
|
if (reconnectAttempts < 10) {
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
|
showDebug(`❌ WS Lost. Reconnecting in ${delay/1000}s...`);
|
|
reconnectAttempts++;
|
|
reconnectTimeout = setTimeout(connectWS, delay);
|
|
} else {
|
|
showDebug(`❌ Max reconnects reached. F1 to reconfigure.`);
|
|
}
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html> |