Files
math/static/app.js
2025-09-13 17:55:36 +03:00

231 lines
7.8 KiB
JavaScript
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.

(() => {
const startBtn = document.getElementById('startBtn');
const timeEl = document.getElementById('time');
const progressEl = document.getElementById('progress');
const problemEl = document.getElementById('problem');
const optionsEl = document.getElementById('options');
const gameEl = document.getElementById('game');
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const fxLayer = document.getElementById('fxLayer');
const btnColorClasses = ['c-yellow','c-orange','c-pink','c-purple','c-blue','c-teal'];
const praise = ['Отлично! ⭐','Молодец! 🎉','Супер! 🌟','Здорово! 🥳','Так держать! 👍'];
// decorative assets removed
let sessionId = null;
let totalQuestions = 20;
let remaining = 300;
let countdown = null;
let inFlight = false;
function setStatus(msg) {
statusEl.textContent = msg || '';
}
function showGame(show) {
gameEl.classList.toggle('hidden', !show);
}
function showResult(score) {
const { correct, total_answered, total } = score;
resultEl.innerHTML = `
<div class="score">Результат: <strong>${correct}</strong> из <strong>${total}</strong></div>
<div class="small">Отвечено: ${total_answered}</div>
<button class="again" id="againBtn">Сыграть ещё</button>
`;
resultEl.classList.remove('hidden');
document.getElementById('againBtn')?.addEventListener('click', () => {
resultEl.classList.add('hidden');
start();
});
}
function renderQuestion(q) {
if (!q) return;
problemEl.textContent = `${q.a} × ${q.b}`;
optionsEl.innerHTML = '';
const colors = shuffle(btnColorClasses).slice(0, q.options.length);
q.options.forEach((value, idx) => {
const btn = document.createElement('button');
btn.className = `option-btn ${colors[idx % colors.length]}`;
btn.textContent = String(value);
btn.addEventListener('click', () => answer(value, btn));
optionsEl.appendChild(btn);
});
progressEl.textContent = `${q.index}/${totalQuestions}`;
}
function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function stopTimer() {
if (countdown) {
clearInterval(countdown);
countdown = null;
}
}
function startTimer(seconds) {
remaining = seconds;
timeEl.textContent = String(remaining);
stopTimer();
countdown = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
remaining = 0;
timeEl.textContent = '0';
stopTimer();
} else {
timeEl.textContent = String(remaining);
}
}, 1000);
}
async function start() {
if (inFlight) return;
inFlight = true;
setStatus('');
showGame(false);
startBtn.disabled = true;
resultEl.classList.add('hidden');
try {
const res = await fetch('/api/session/start', { method: 'POST' });
if (!res.ok) throw new Error('Не удалось начать сессию');
const data = await res.json();
sessionId = data.session_id;
totalQuestions = data.total_questions;
startTimer(data.remaining_seconds);
renderQuestion(data.question);
showGame(true);
setStatus('Отвечайте как можно быстрее!');
} catch (e) {
console.error(e);
setStatus('Ошибка старта. Попробуйте ещё раз.');
} finally {
startBtn.disabled = false;
inFlight = false;
}
}
function celebrateFromElement(el) {
if (!fxLayer) return;
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const count = 40;
for (let i = 0; i < count; i++) {
const p = document.createElement('span');
p.className = 'confetti';
// origin
p.style.left = `${x}px`;
p.style.top = `${y}px`;
// random trajectory
const angle = Math.random() * Math.PI * 2;
const distance = 60 + Math.random() * 80;
const tx = Math.cos(angle) * distance;
const ty = Math.sin(angle) * distance + 40 + Math.random() * 60; // gravity pull
const rot = (Math.random() * 720 - 360).toFixed(0) + 'deg';
p.style.setProperty('--tx', `${tx.toFixed(0)}px`);
p.style.setProperty('--ty', `${ty.toFixed(0)}px`);
p.style.setProperty('--rot', rot);
const hue = Math.floor(Math.random() * 360);
p.style.backgroundColor = `hsl(${hue} 90% 55%)`;
p.style.animation = `confetti ${650 + Math.random() * 400}ms ease-out forwards`;
fxLayer.appendChild(p);
setTimeout(() => p.remove(), 1200);
}
}
function celebrateFullscreen() {
if (!fxLayer) return;
// quick flash overlay
const flash = document.createElement('div');
flash.className = 'flash';
fxLayer.appendChild(flash);
setTimeout(() => flash.remove(), 550);
const vw = window.innerWidth;
const vh = window.innerHeight;
const count = Math.min(220, Math.floor((vw * vh) / 7000)); // scale with viewport
for (let i = 0; i < count; i++) {
const p = document.createElement('span');
p.className = 'confetti';
// random start across the screen
const sx = Math.random() * vw;
const sy = Math.random() * vh * 0.6 + vh * 0.1; // avoid extreme edges
p.style.left = `${sx}px`;
p.style.top = `${sy}px`;
// random trajectory
const angle = Math.random() * Math.PI * 2;
const distance = 180 + Math.random() * 520;
const tx = Math.cos(angle) * distance;
const ty = Math.sin(angle) * distance + 80; // slight gravity bias
const rot = (Math.random() * 1080 - 540).toFixed(0) + 'deg';
p.style.setProperty('--tx', `${tx.toFixed(0)}px`);
p.style.setProperty('--ty', `${ty.toFixed(0)}px`);
p.style.setProperty('--rot', rot);
const hue = Math.floor(Math.random() * 360);
p.style.backgroundColor = `hsl(${hue} 90% 55%)`;
const dur = 1000 + Math.random() * 900;
p.style.animation = `confetti ${dur}ms ease-out forwards`;
fxLayer.appendChild(p);
setTimeout(() => p.remove(), dur + 200);
}
}
async function answer(value, clickedBtn) {
if (!sessionId || inFlight) return;
inFlight = true;
// Disable all buttons to prevent double clicks
const buttons = Array.from(optionsEl.querySelectorAll('button'));
buttons.forEach(b => b.disabled = true);
try {
const res = await fetch(`/api/session/${sessionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: value }),
});
if (!res.ok) throw new Error('Не удалось отправить ответ');
const data = await res.json();
// feedback
if (data.correct) {
clickedBtn.classList.add('correct');
celebrateFullscreen();
setStatus(praise[Math.floor(Math.random() * praise.length)]);
} else {
clickedBtn.classList.add('wrong');
}
// Small pause to show feedback
await new Promise(r => setTimeout(r, data.correct ? 350 : 250));
if (data.finished) {
stopTimer();
showGame(false);
const reason = data.reason === 'timeout' ? 'Время вышло.' : 'Завершено!';
setStatus(reason);
showResult(data.score);
} else {
timeEl.textContent = String(data.remaining_seconds);
renderQuestion(data.question);
}
} catch (e) {
console.error(e);
setStatus('Ошибка ответа. Перезапустите сессию.');
} finally {
inFlight = false;
}
}
startBtn.addEventListener('click', start);
// no decor positioning
})();