kdscalc / index.html
bakhtrv's picture
Update index.html
a426569 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>FIDA Controller & OBS</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
<style>
/* ОБЩИЕ СТИЛИ */
body {
font-family: 'Inter', sans-serif;
background-color: #121212;
color: white;
height: 100vh;
overflow: hidden;
user-select: none;
margin: 0;
}
.orbitron { font-family: 'Orbitron', sans-serif; }
.wire-border { border: 1px solid rgba(255, 255, 255, 0.3); }
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* СТИЛИ ДЛЯ OBS */
#obsView {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: #000;
align-items: center;
justify-content: center;
z-index: 9999;
}
#display-value {
font-family: 'Orbitron', sans-serif;
color: #fff;
font-size: 50vh;
font-weight: 900;
line-height: 1;
text-align: center;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* СТИЛИ КОНТРОЛЛЕРА */
.team-select-embedded {
background: transparent; color: white; font-weight: 700;
text-align: center; width: 100%; outline: none; appearance: none;
border: none; text-transform: uppercase;
}
.team-select-embedded option { background-color: #121212; }
.tab-active-red { background-color: rgba(220, 38, 38, 0.3); border-bottom: 2px solid #ef4444; }
.tab-active-blue { background-color: rgba(37, 99, 235, 0.3); border-bottom: 2px solid #3b82f6; }
.play-circle {
width: 140px; height: 140px; border: 2px solid white; border-radius: 50%;
display: flex; align-items: center; justify-content: center; transition: transform 0.1s;
}
.play-circle:active { transform: scale(0.95); background: rgba(255,255,255,0.1); }
.pilot-card.selected { background-color: rgba(255,255,255,0.3); border-color: white; }
</style>
</head>
<body>
<!-- ================= ИНТЕРФЕЙС КОНТРОЛЛЕРА ================= -->
<div id="controller-ui" class="flex flex-col h-full w-full p-2 gap-2">
<!-- ВЕРХНЯЯ ПАНЕЛЬ -->
<div class="h-16 flex gap-2 shrink-0">
<button onclick="toggleMode()" class="w-1/4 wire-border flex flex-col items-center justify-center hover:bg-white/10">
<span class="text-[10px] text-gray-400">MODE</span>
<span id="modeLabel" class="font-bold orbitron text-sm">MATCH</span>
</button>
<!-- Контекст: МАТЧ -->
<div id="topContextMatch" class="w-3/4 flex gap-2">
<div class="flex-1 wire-border flex items-center justify-between px-2">
<button onclick="updateSet(-1)" class="text-3xl font-bold px-4 hover:text-gray-300">-</button>
<div class="text-center">
<div class="text-[10px] text-gray-400">SET</div>
<div id="currentSet" class="text-3xl font-bold orbitron">1</div>
</div>
<button onclick="updateSet(1)" class="text-3xl font-bold px-4 hover:text-gray-300">+</button>
</div>
<button onclick="saveMatchToHistory()" id="btnSaveMatch" class="w-16 wire-border flex items-center justify-center hover:bg-white/10">
💾
</button>
</div>
<!-- Контекст: ПИЛОТ -->
<div id="topContextPilot" class="w-3/4 wire-border hidden flex-col justify-center px-2 relative">
<select id="spTeamSelect" onchange="loadPilots(this.value)" class="bg-black text-center font-bold text-lg outline-none w-full uppercase">
<option value="">-- SELECT TEAM --</option>
</select>
</div>
</div>
<!-- ================= РЕЖИМ МАТЧА ================= -->
<div id="matchInterface" class="flex-1 flex flex-col gap-2">
<!-- ТАЙМЕР МАТЧА -->
<div class="h-24 wire-border flex items-center justify-between px-4 bg-white/5">
<div class="flex flex-col">
<span class="text-[10px] text-gray-400">MATCH TIMER</span>
<div id="ctrlMatchTimer" class="text-5xl font-bold orbitron text-yellow-500 tracking-wider">03:00</div>
</div>
<div class="flex gap-2">
<button onclick="toggleMatchTimer()" class="w-12 h-12 border border-white/20 flex items-center justify-center hover:bg-white/10">
<svg id="mtPlay" class="w-6 h-6 fill-white" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="mtPause" class="w-6 h-6 fill-white hidden" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button onclick="resetMatchTimer()" class="w-12 h-12 border border-white/20 flex items-center justify-center hover:bg-white/10">
</button>
<button onclick="copyLink('timer')" id="btnLinkTimer" class="w-16 h-12 border border-white/20 flex flex-col items-center justify-center hover:bg-white/10 text-[10px]">
🔗 LINK
</button>
</div>
</div>
<!-- ВЫБОР КОМАНД -->
<div class="h-14 flex gap-2">
<div id="tabRed" onclick="selectActiveTeam('red')" class="w-1/2 wire-border flex flex-col items-center justify-center relative transition-all">
<span class="text-[10px] opacity-70">RED <span id="winsRed">(0)</span></span>
<select id="selectRedTeam" onchange="changeMatchTeam('red', this.value)" class="team-select-embedded"><option>Loading...</option></select>
</div>
<div id="tabBlue" onclick="selectActiveTeam('blue')" class="w-1/2 wire-border flex flex-col items-center justify-center relative transition-all opacity-50">
<span class="text-[10px] opacity-70">BLUE <span id="winsBlue">(0)</span></span>
<select id="selectBlueTeam" onchange="changeMatchTeam('blue', this.value)" class="team-select-embedded"><option>Loading...</option></select>
</div>
</div>
<!-- СЧЕТ -->
<div class="flex-1 wire-border relative flex flex-col justify-evenly bg-white/5">
<!-- POINTS -->
<div class="flex items-center justify-center gap-6">
<button class="text-4xl px-4 py-2" onclick="updateMatchValue('points', -1)">-</button>
<div class="text-center min-w-[120px]">
<div class="text-[10px] text-gray-400">POINTS</div>
<div id="displayPoints" class="text-5xl font-bold orbitron">0</div>
</div>
<button class="text-4xl px-4 py-2" onclick="updateMatchValue('points', 1)">+</button>
</div>
<!-- PENALTY -->
<div class="flex items-center justify-center gap-6">
<button class="text-4xl px-4 py-2" onclick="updateMatchValue('penalty', -1)">-</button>
<div class="text-center min-w-[120px]">
<div class="text-[10px] text-gray-400">PENALTY</div>
<div id="displayPenalty" class="text-5xl font-bold orbitron">0</div>
</div>
<button class="text-4xl px-4 py-2" onclick="updateMatchValue('penalty', 1)">+</button>
</div>
<!-- TOTAL -->
<div class="text-center border-t border-white/10 pt-2">
<div class="text-[10px] text-gray-400">SCORE</div>
<div id="displayTotalScore" class="text-4xl font-black orbitron text-yellow-400">0.0</div>
</div>
</div>
</div>
<!-- ================= РЕЖИМ ПИЛОТА (SUPERPILOT) ================= -->
<div id="superpilotInterface" class="hidden flex-1 flex-col gap-4 py-2">
<div id="pilotList" class="flex gap-3 overflow-x-auto h-32 shrink-0 scrollbar-hide px-1 border border-white/10 p-1">
<div class="w-full h-full flex items-center justify-center text-gray-500 text-sm">Select Team First</div>
</div>
<div class="flex-1 flex flex-col items-center justify-center">
<div class="text-[10px] text-gray-400 mb-2">PILOT STOPWATCH</div>
<div id="ctrlStopwatch" class="text-6xl font-bold orbitron tracking-wider mb-6">00:00:00</div>
<button onclick="toggleStopwatch()" id="btnSpAction" class="play-circle hover:bg-white/10">
<svg id="spPlay" class="w-16 h-16 fill-white ml-2" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="spPause" class="w-16 h-16 fill-white hidden" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<div class="flex gap-4 mt-8 w-full px-4">
<button onclick="copyLink('stopwatch')" id="btnLinkStopwatch" class="flex-1 h-12 border border-white/20 font-bold text-xs hover:bg-white/10">
COPY OBS LINK
</button>
<button onclick="saveSuperpilot()" class="flex-1 h-12 wire-border bg-green-900/30 font-bold text-xs hover:bg-green-900/50">
SAVE RESULT
</button>
</div>
<div id="spMsg" class="h-4 text-green-400 text-xs font-bold mt-2"></div>
</div>
</div>
</div>
<!-- ================= OBS VIEW ================= -->
<div id="obsView"><div id="display-value">00:00</div></div>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.8.0/firebase-app.js";
import { getDatabase, ref, onValue, update, push, set } from "https://www.gstatic.com/firebasejs/10.8.0/firebase-database.js";
const firebaseConfig = {
apiKey: "AIzaSyDi6znbXgOFR7aXZ9v7aVFP065yE98T7XU",
authDomain: "fida-kz.firebaseapp.com",
databaseURL: "https://fida-kz-default-rtdb.firebaseio.com",
projectId: "fida-kz",
appId: "1:728249202318:web:29ee414ef7eff47573fbdf"
};
const app = initializeApp(firebaseConfig);
const db = getDatabase(app);
let mode = "match";
let activeTeam = "red";
let teamsCache = {};
let matchData = {};
let selectedPilot = null;
const MATCH_DURATION = 120;
// --- OBS MODE ---
const params = new URLSearchParams(window.location.search);
const viewMode = params.get('view');
if (viewMode) {
document.getElementById('controller-ui').style.display = 'none';
document.getElementById('obsView').style.display = 'flex';
document.body.style.backgroundColor = 'black';
}
// --- ANIMATION LOOP ---
let pilotTimerState = { running: false, startAt: 0, accum: 0 };
let matchTimerState = { running: false, target: 0, remaining: MATCH_DURATION };
function animationLoop() {
const now = Date.now();
let matchSec = matchTimerState.remaining;
if (matchTimerState.running) {
matchSec = Math.max(0, matchTimerState.target - (now / 1000));
}
const mtStr = formatTime(matchSec, false);
let pilotMs = pilotTimerState.accum;
if (pilotTimerState.running) {
pilotMs = pilotTimerState.accum + (now - pilotTimerState.startAt);
}
const spStr = formatTime(pilotMs / 1000, true);
if (viewMode === 'timer') document.getElementById('display-value').innerText = mtStr;
else if (viewMode === 'stopwatch') document.getElementById('display-value').innerText = spStr;
else {
const elM = document.getElementById('ctrlMatchTimer');
const elP = document.getElementById('ctrlStopwatch');
if (elM) elM.innerText = mtStr;
if (elP) elP.innerText = spStr;
}
requestAnimationFrame(animationLoop);
}
requestAnimationFrame(animationLoop);
function formatTime(totalSeconds, includeMs) {
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
const ms = Math.floor((totalSeconds % 1) * 100);
let str = `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
if (includeMs) str += `:${ms.toString().padStart(2,'0')}`;
return str;
}
// --- FIREBASE LISTENERS ---
onValue(ref(db, 'active_match'), (snap) => {
matchData = snap.val() || {};
if (matchData.match_timer) {
matchTimerState.running = matchData.match_timer.running || false;
matchTimerState.target = matchData.match_timer.target_time || 0;
matchTimerState.remaining = (matchData.match_timer.remaining !== undefined) ? matchData.match_timer.remaining : MATCH_DURATION;
}
if (matchData.meta) {
const s = matchData.meta.seconds || 0;
const us = matchData.meta.microseconds || 0;
pilotTimerState.accum = (s * 1000) + us;
pilotTimerState.startAt = matchData.meta.started || 0;
pilotTimerState.running = (matchData.meta.started > 0);
}
updateButtonStates();
if (!viewMode) updateControllerUI(matchData);
});
onValue(ref(db, 'teams'), (snap) => {
teamsCache = snap.val() || {};
if (!viewMode) populateTeams();
});
// --- UPDATED LOGIC FOR SYNC ---
window.updateSet = (delta) => {
const current = (matchData && matchData.current_set) ? parseInt(matchData.current_set) : 1;
const next = Math.max(1, current + delta);
const updates = {};
updates['active_match/current_set'] = next;
// Default empty structure
const emptySet = {
team_red: { points: 0, penalty: 0, score: 0 },
team_blue: { points: 0, penalty: 0, score: 0 }
};
// Get existing data if available, else use empty
const nextSetData = (matchData.meta && matchData.meta.sets && matchData.meta.sets[next])
? matchData.meta.sets[next]
: emptySet;
// 1. UPDATE HISTORY if needed
if (!matchData.meta?.sets?.[next]) {
updates[`active_match/meta/sets/${next}`] = emptySet;
}
// 2. UPDATE MIRROR (This fixes the issue!)
updates[`active_match/current_set_data`] = nextSetData;
update(ref(db), updates).catch(console.error);
};
window.updateMatchValue = (type, delta) => {
const set = matchData.current_set || 1;
const key = `team_${activeTeam}`;
const pathHistory = `active_match/meta/sets/${set}/${key}`;
const pathMirror = `active_match/current_set_data/${key}`;
const currentData = matchData.meta?.sets?.[set]?.[key] || {};
let pts = currentData.points || 0;
let pen = currentData.penalty || 0;
if (type === 'points') pts = Math.max(0, pts + delta);
if (type === 'penalty') pen = Math.max(0, pen + delta);
const score = parseFloat((pts + (pen * 0.8)).toFixed(2));
// UPDATE BOTH PATHS AT ONCE
const updates = {};
// History
updates[`${pathHistory}/points`] = pts;
updates[`${pathHistory}/penalty`] = pen;
updates[`${pathHistory}/score`] = score;
// Mirror
updates[`${pathMirror}/points`] = pts;
updates[`${pathMirror}/penalty`] = pen;
updates[`${pathMirror}/score`] = score;
update(ref(db), updates).then(() => calculateWins());
};
// ------------------------------------
window.toggleMatchTimer = () => {
const nowSec = Date.now() / 1000;
if (matchTimerState.running) {
const left = Math.max(0, matchTimerState.target - nowSec);
update(ref(db, 'active_match/match_timer'), { running: false, remaining: left, target_time: 0 });
} else {
const target = nowSec + matchTimerState.remaining;
update(ref(db, 'active_match/match_timer'), { running: true, target_time: target });
}
};
window.resetMatchTimer = () => {
update(ref(db, 'active_match/match_timer'), { running: false, remaining: MATCH_DURATION, target_time: 0 });
};
window.toggleStopwatch = () => {
if (!selectedPilot && !pilotTimerState.running && pilotTimerState.accum === 0) {
alert("SELECT PILOT FIRST!"); return;
}
const now = Date.now();
if (pilotTimerState.running) {
const currentAccum = pilotTimerState.accum + (now - pilotTimerState.startAt);
update(ref(db, 'active_match/meta'), { started: 0, seconds: Math.floor(currentAccum / 1000), microseconds: currentAccum % 1000 });
} else {
update(ref(db, 'active_match/meta'), { started: now });
}
};
window.saveSuperpilot = () => {
if (!selectedPilot) return;
const now = Date.now();
let finalMs = pilotTimerState.accum;
if (pilotTimerState.running) finalMs += (now - pilotTimerState.startAt);
const record = {
uid: selectedPilot.id, name: selectedPilot.name, team_name: selectedPilot.teamName,
image: selectedPilot.image, seconds: Math.floor(finalMs / 1000), milliseconds: finalMs,
started: Math.floor(now / 1000)
};
const msg = document.getElementById('spMsg');
msg.innerText = "SAVING...";
push(ref(db, 'history/eventkey1/superpilot'), record).then(() => {
msg.innerText = "SAVED!";
setTimeout(() => msg.innerText = "", 2000);
update(ref(db, 'active_match/meta'), { started: 0, seconds: 0, microseconds: 0 });
});
};
function calculateWins() {
let red = 0, blue = 0;
const sets = matchData.meta?.sets || {};
Object.values(sets).forEach(s => {
if (s && s.team_red && s.team_blue) {
if (s.team_red.score > s.team_blue.score) red++;
if (s.team_blue.score > s.team_red.score) blue++;
}
});
update(ref(db, 'active_match/team_red/sets_won'), red);
update(ref(db, 'active_match/team_blue/sets_won'), blue);
}
window.saveMatchToHistory = () => {
const record = {
timestamp: Date.now(),
meta: matchData.meta || {},
team_red: matchData.team_red,
team_blue: matchData.team_blue
};
push(ref(db, 'history/eventkey1/match'), record).then(() => alert('Match Saved!'));
};
window.toggleMode = () => {
mode = (mode === 'match') ? 'pilot' : 'match';
document.getElementById('matchInterface').classList.toggle('hidden', mode !== 'match');
document.getElementById('matchInterface').classList.toggle('flex', mode === 'match');
document.getElementById('topContextMatch').classList.toggle('hidden', mode !== 'match');
document.getElementById('topContextMatch').classList.toggle('flex', mode === 'match');
document.getElementById('superpilotInterface').classList.toggle('hidden', mode !== 'pilot');
document.getElementById('superpilotInterface').classList.toggle('flex', mode === 'pilot');
document.getElementById('topContextPilot').classList.toggle('hidden', mode !== 'pilot');
document.getElementById('topContextPilot').classList.toggle('flex', mode === 'pilot');
document.getElementById('modeLabel').innerText = mode.toUpperCase();
};
window.selectActiveTeam = (t) => { activeTeam = t; updateControllerUI(matchData); };
window.loadPilots = (teamId) => {
const list = document.getElementById('pilotList');
list.innerHTML = "";
selectedPilot = null;
update(ref(db, 'active_match/meta'), { started: 0, seconds: 0, microseconds: 0 });
if (!teamId || !teamsCache[teamId]) return;
const team = teamsCache[teamId];
const roster = team.active_roster || Object.values(team.members || {});
roster.forEach((p, idx) => {
const div = document.createElement('div');
div.className = "min-w-[100px] h-full border border-gray-600 bg-black cursor-pointer pilot-card flex flex-col justify-end relative";
if(p.image) div.style = `background-image: url('${p.image}'); background-size: cover;`;
div.innerHTML = `<div class="bg-black/70 text-center text-xs p-1 truncate text-white w-full">${p.name}</div>`;
div.onclick = () => {
document.querySelectorAll('.pilot-card').forEach(el => el.classList.remove('selected'));
div.classList.add('selected');
selectedPilot = { ...p, id: p.id || `tmp_${idx}`, teamName: team.name };
update(ref(db, 'active_match/meta'), { started: 0, seconds: 0, microseconds: 0 });
update(ref(db, 'active_match/pilot'), { name: p.name, image: p.image || "", team_name: team.name });
};
list.appendChild(div);
});
};
window.copyLink = (type) => {
const url = `${window.location.origin}${window.location.pathname}?view=${type}`;
navigator.clipboard.writeText(url);
const btn = document.getElementById(type === 'timer' ? 'btnLinkTimer' : 'btnLinkStopwatch');
const old = btn.innerText;
btn.innerText = "COPIED";
setTimeout(() => btn.innerText = old, 1500);
};
window.changeMatchTeam = (color, id) => {
if(!teamsCache[id]) return;
const t = teamsCache[id];
set(ref(db, `active_match/team_${color}`), {
id: id, name: t.name, logo: t.logo||"",
roster: t.active_roster || Object.values(t.members||{}),
sets_won: 0, warnings: 0
});
};
function populateTeams() {
const opts = ['<option value="">-- CHOOSE --</option>'];
Object.entries(teamsCache).forEach(([k,v]) => opts.push(`<option value="${k}">${v.name}</option>`));
const els = ['selectRedTeam', 'selectBlueTeam', 'spTeamSelect'];
els.forEach(id => {
const el = document.getElementById(id);
if(el && el.children.length <= 1) el.innerHTML = opts.join('');
});
}
function updateButtonStates() {
const mtPlay = document.getElementById('mtPlay');
const mtPause = document.getElementById('mtPause');
if(mtPlay) {
if(matchTimerState.running) { mtPlay.classList.add('hidden'); mtPause.classList.remove('hidden'); }
else { mtPlay.classList.remove('hidden'); mtPause.classList.add('hidden'); }
}
const spPlay = document.getElementById('spPlay');
const spPause = document.getElementById('spPause');
if(spPlay) {
if(pilotTimerState.running) { spPlay.classList.add('hidden'); spPause.classList.remove('hidden'); }
else { spPlay.classList.remove('hidden'); spPause.classList.add('hidden'); }
}
}
function updateControllerUI(data) {
const r = document.getElementById('tabRed');
const b = document.getElementById('tabBlue');
if(activeTeam === 'red') {
r.className = r.className.replace('opacity-50', 'tab-active-red');
b.className = b.className.replace('tab-active-blue', '').replace('tab-active-red', '') + ' opacity-50';
} else {
b.className = b.className.replace('opacity-50', 'tab-active-blue');
r.className = r.className.replace('tab-active-blue', '').replace('tab-active-red', '') + ' opacity-50';
}
const set = data.current_set || 1;
document.getElementById('currentSet').innerText = set;
const setData = data.meta?.sets?.[set]?.[`team_${activeTeam}`] || {};
document.getElementById('displayPoints').innerText = setData.points || 0;
document.getElementById('displayPenalty').innerText = setData.penalty || 0;
document.getElementById('displayTotalScore').innerText = (setData.score || 0).toFixed(1);
document.getElementById('winsRed').innerText = `(${data.team_red?.sets_won || 0})`;
document.getElementById('winsBlue').innerText = `(${data.team_blue?.sets_won || 0})`;
}
</script>
</body>
</html>