Загрузка данных
'use strict';
/* ================= УТИЛИТЫ ================= */
const $ = (s) => document.querySelector(s);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// Честный рандом через криптографию
function rollDie() {
const a = new Uint32Array(1);
crypto.getRandomValues(a);
return (a[0] % 6) + 1;
}
// Перезапуск CSS-анимации
function replay(node, cls) {
node.classList.remove(cls);
void node.offsetWidth;
node.classList.add(cls);
}
// Плавный счётчик чисел
function animateNumber(node, to) {
const from = Number(node.textContent) || 0;
if (from === to) { node.textContent = to; return; }
const start = performance.now();
const dur = 600;
const step = (now) => {
const p = Math.min((now - start) / dur, 1);
const e = 1 - Math.pow(1 - p, 3);
node.textContent = Math.round(from + (to - from) * e);
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
/* ================= ХРАНИЛИЩЕ ================= */
const DB_KEY = 'dice_arena_db_v2';
const SESSION_KEY = 'dice_arena_session';
const SOUND_KEY = 'dice_arena_sound';
const storage = {
load() {
try { return JSON.parse(localStorage.getItem(DB_KEY)) || {}; }
catch { return {}; }
},
save(db) { localStorage.setItem(DB_KEY, JSON.stringify(db)); },
};
let db = storage.load();
// Пароль хранится только как SHA-256 хэш
async function hashPassword(name, pass) {
const text = `${name}:${pass}:dice-arena`;
if (window.crypto && crypto.subtle) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
let h = 0;
for (const ch of text) h = (h * 31 + ch.charCodeAt(0)) | 0;
return 'f' + h;
}
/* ================= DOM ================= */
const el = {
auth: $('#auth-screen'), game: $('#game-screen'), form: $('#auth-form'),
username: $('#username'), password: $('#password'), togglePass: $('#toggle-pass'),
user: $('#current-user'), avatar: $('#avatar'), logout: $('#logout-btn'),
pWins: $('#total-player-wins'), cWins: $('#total-computer-wins'), streak: $('#streak'),
roll: $('#roll-btn'), stop: $('#stop-btn'), next: $('#next-round-btn'),
log: $('#round-log'), cube: $('#cube'), diceBox: $('#dice-box'), round: $('#round-num'),
history: $('#history'), leaders: $('#leaderboard'),
sound: $('#sound-btn'), rules: $('#rules-btn'), rulesModal: $('#rules-modal'), rulesClose: $('#rules-close'),
toasts: $('#toasts'),
side: {
player: { zone: $('#player-zone'), sum: $('#player-sum'), meter: $('#player-meter'), dice: $('#player-dice'), attempts: $('#player-attempts') },
computer: { zone: $('#computer-zone'), sum: $('#computer-sum'), meter: $('#computer-meter'), dice: $('#computer-dice'), attempts: $('#computer-attempts') },
},
};
/* ================= СОСТОЯНИЕ ================= */
const MAX_ROLLS = 3;
const LIMIT = 12;
const state = {
userKey: null,
round: 1,
phase: 'player', // player | computer | over
busy: false,
token: 0, // защищает от "хвостов" анимаций после выхода/сброса
player: { sum: 0, rolls: [] },
computer: { sum: 0, rolls: [] },
};
const currentUser = () => db[state.userKey];
/* ================= ЗВУК (Web Audio) ================= */
let audioCtx = null;
let soundOn = localStorage.getItem(SOUND_KEY) !== 'off';
function tone(freq, dur = 0.08, type = 'triangle', vol = 0.06, delay = 0) {
if (!soundOn) return;
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const t = audioCtx.currentTime + delay;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(audioCtx.destination);
o.start(t);
o.stop(t + dur);
}
const sfx = {
roll() { for (let i = 0; i < 7; i++) tone(160 + Math.random() * 140, 0.04, 'square', 0.02, i * 0.08); },
land() { tone(130, 0.14, 'sine', 0.14); },
click() { tone(620, 0.04, 'sine', 0.04); },
win() { [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.2, 'triangle', 0.07, i * 0.1)); },
lose() { [392, 311, 233].forEach((f, i) => tone(f, 0.24, 'sawtooth', 0.035, i * 0.13)); },
};
function renderSoundBtn() {
el.sound.innerHTML = `<i class="fa-solid fa-volume-${soundOn ? 'high' : 'xmark'}"></i>`;
}
/* ================= ТОСТЫ ================= */
function toast(text, type = 'info') {
const icons = { info: 'fa-circle-info', success: 'fa-circle-check', error: 'fa-circle-exclamation' };
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<i class="fa-solid ${icons[type]}"></i><span></span>`;
t.querySelector('span').textContent = text;
el.toasts.append(t);
setTimeout(() => {
t.classList.add('out');
t.addEventListener('animationend', () => t.remove(), { once: true });
}, 2600);
}
/* ================= 3D КУБИК ================= */
// Позиции точек в сетке 3x3 (индексы 0..8)
const PIPS = {
1: [4],
2: [2, 6],
3: [2, 4, 6],
4: [0, 2, 6, 8],
5: [0, 2, 4, 6, 8],
6: [0, 2, 3, 5, 6, 8],
};
// Поворот куба [X, Y], чтобы нужная грань смотрела на игрока
const SHOW = { 1: [0, 0], 2: [0, -90], 3: [-90, 0], 4: [90, 0], 5: [0, 90], 6: [0, 180] };
let spins = 0;
function buildCube() {
el.cube.innerHTML = Object.keys(PIPS).map((n) => `
<div class="face f${n}">
${Array.from({ length: 9 }, (_, i) => `<span class="${PIPS[n].includes(i) ? 'pip' : ''}"></span>`).join('')}
</div>`).join('');
}
async function throwDie(value) {
sfx.roll();
spins += 2;
const [x, y] = SHOW[value];
el.cube.style.transform = `rotateX(${x + 360 * spins}deg) rotateY(${y + 360 * spins}deg)`;
replay(el.diceBox, 'rolling');
await wait(1000);
sfx.land();
return value;
}
/* ================= ОТРИСОВКА ================= */
function renderSide(who) {
const s = state[who];
const ui = el.side[who];
if (s.rolls.length === 0) {
ui.sum.textContent = '0';
ui.dice.innerHTML = '<span class="chip-empty">Бросков ещё не было</span>';
} else {
animateNumber(ui.sum, s.sum);
replay(ui.sum, 'bump');
ui.dice.querySelector('.chip-empty')?.remove();
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = s.rolls[s.rolls.length - 1];
ui.dice.append(chip);
}
const warn = s.sum >= 9 && s.sum < LIMIT;
const bust = s.sum >= LIMIT;
ui.sum.classList.toggle('warn', warn);
ui.sum.classList.toggle('bust', bust);
ui.meter.classList.toggle('warn', warn);
ui.meter.classList.toggle('bust', bust);
ui.meter.style.width = `${Math.min(s.sum / LIMIT, 1) * 100}%`;
ui.attempts.textContent = MAX_ROLLS - s.rolls.length;
}
function syncUI() {
const p = state.player;
const playerTurn = state.phase === 'player' && !state.busy;
el.roll.disabled = !playerTurn || p.rolls.length >= MAX_ROLLS;
el.stop.disabled = !playerTurn || p.rolls.length === 0;
el.side.player.zone.classList.toggle('active', state.phase === 'player');
el.side.computer.zone.classList.toggle('active', state.phase === 'computer');
el.roll.classList.toggle('hidden', state.phase === 'over');
el.stop.classList.toggle('hidden', state.phase === 'over');
el.next.classList.toggle('hidden', state.phase !== 'over');
}
function setLog(text, type = 'system') {
el.log.className = `log card ${type}`;
el.log.innerHTML = `<p class="${type}-msg"></p>`;
el.log.firstChild.textContent = text;
}
function renderScore() {
const u = currentUser();
animateNumber(el.pWins, u.wins.player);
animateNumber(el.cWins, u.wins.computer);
animateNumber(el.streak, u.streak);
}
const LABEL = { win: 'Победа', lose: 'Поражение', draw: 'Ничья' };
function renderHistory() {
const h = currentUser().history;
if (!h.length) {
el.history.innerHTML = '<li class="empty">Сыграйте первый раунд</li>';
return;
}
el.history.innerHTML = h.map((r, i) => `
<li style="animation-delay:${i * 40}ms">
<span class="tag ${r.result}">${LABEL[r.result]}</span>
<span class="score-mini">${r.p} : ${r.c}</span>
</li>`).join('');
}
function renderLeaders() {
const list = Object.entries(db)
.map(([key, u]) => ({ key, name: u.name, wins: u.wins.player, lost: u.wins.computer }))
.sort((a, b) => b.wins - a.wins || a.lost - b.lost)
.slice(0, 5);
el.leaders.innerHTML = list.map((u, i) => `
<li class="${u.key === state.userKey ? 'me' : ''}" style="animation-delay:${i * 50}ms">
<span class="rank">${i + 1}</span>
<span class="name">${escapeHtml(u.name)}</span>
<span class="score-mini">${u.wins}</span>
</li>`).join('');
}
/* ================= ЛОГИКА ИГРЫ ================= */
async function playerRoll() {
const p = state.player;
if (state.phase !== 'player' || state.busy || p.rolls.length >= MAX_ROLLS) return;
const token = state.token;
state.busy = true;
syncUI();
const v = await throwDie(rollDie());
if (token !== state.token) return;
p.rolls.push(v);
p.sum += v;
renderSide('player');
state.busy = false;
if (p.sum >= LIMIT) return finish('player_bust');
if (p.rolls.length === MAX_ROLLS) return playerStop();
setLog(`Выпало ${v}. Сумма ${p.sum}. ${p.sum >= 9 ? 'Рискованно! ' : ''}Бросаете ещё или пас?`);
syncUI();
}
function playerStop() {
if (state.phase !== 'player' || state.busy || !state.player.rolls.length) return;
sfx.click();
computerTurn();
}
async function computerTurn() {
const token = state.token;
const c = state.computer;
const target = state.player.sum;
state.phase = 'computer';
state.busy = true;
syncUI();
setLog(`Вы остановились на ${target}. Компьютер думает…`);
await wait(700);
if (token !== state.token) return;
// ИИ: бросает, пока отстаёт; при равенстве рискует, только если сумма небольшая
while (c.rolls.length < MAX_ROLLS) {
const mustRoll = c.rolls.length === 0 || c.sum < target;
const gamble = c.sum === target && c.sum <= 6;
if (!mustRoll && !gamble) break;
const v = await throwDie(rollDie());
if (token !== state.token) return;
c.rolls.push(v);
c.sum += v;
renderSide('computer');
if (c.sum >= LIMIT) {
state.busy = false;
return finish('computer_bust');
}
await wait(450);
if (token !== state.token) return;
}
state.busy = false;
finish('compare');
}
function finish(reason) {
const p = state.player;
const c = state.computer;
let result, text;
if (reason === 'player_bust') {
result = 'lose'; text = `Перебор: ${p.sum}. Раунд проигран.`;
} else if (reason === 'computer_bust') {
result = 'win'; text = `Компьютер перебрал (${c.sum}). Победа за вами!`;
} else if (p.sum > c.sum) {
result = 'win'; text = `${p.sum} против ${c.sum}. Вы победили!`;
} else if (p.sum < c.sum) {
result = 'lose'; text = `${p.sum} против ${c.sum}. Компьютер оказался сильнее.`;
} else {
result = 'draw'; text = `${p.sum} : ${c.sum}. Ничья!`;
}
const u = currentUser();
if (result === 'win') { u.wins.player++; u.streak++; }
if (result === 'lose') { u.wins.computer++; u.streak = 0; }
u.best = Math.max(u.best || 0, u.streak);
u.history.unshift({ result, p: p.sum, c: c.sum });
u.history = u.history.slice(0, 8);
storage.save(db);
state.phase = 'over';
syncUI();
setLog(text, result);
renderScore();
renderHistory();
renderLeaders();
if (result === 'win') {
el.side.player.zone.classList.add('winner');
sfx.win();
celebrate();
if (u.streak >= 3) toast(`Серия из ${u.streak} побед подряд!`, 'success');
} else if (result === 'lose') {
el.side.computer.zone.classList.add('winner');
replay(el.side.player.zone, 'shake');
sfx.lose();
} else {
sfx.click();
}
}
function celebrate() {
if (typeof confetti !== 'function') return;
const colors = ['#e8b04b', '#f3cf7e', '#d9826b', '#8fbf8a', '#f7f0e4'];
const r = el.side.player.zone.getBoundingClientRect();
const origin = { x: (r.left + r.width / 2) / innerWidth, y: (r.top + r.height / 2) / innerHeight };
confetti({ particleCount: 90, spread: 75, startVelocity: 38, origin, colors, scalar: 0.9 });
setTimeout(() => confetti({ particleCount: 60, angle: 60, spread: 60, origin: { x: 0, y: 0.8 }, colors }), 200);
setTimeout(() => confetti({ particleCount: 60, angle: 120, spread: 60, origin: { x: 1, y: 0.8 }, colors }), 350);
}
function resetRound(nextRound = true) {
state.token++;
if (nextRound) state.round++;
state.phase = 'player';
state.busy = false;
state.player = { sum: 0, rolls: [] };
state.computer = { sum: 0, rolls: [] };
renderSide('player');
renderSide('computer');
el.side.player.zone.classList.remove('winner');
el.side.computer.zone.classList.remove('winner');
el.round.textContent = state.round;
setLog(nextRound ? 'Новый раунд. Бросайте кубик!' : 'Арена готова. Сделайте первый бросок!');
syncUI();
}
/* ================= АВТОРИЗАЦИЯ ================= */
function fail(msg) {
toast(msg, 'error');
replay(el.form, 'shake');
}
el.form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = el.username.value.trim();
const pass = el.password.value;
if (name.length < 2) return fail('Ник должен быть от 2 символов');
if (pass.length < 4) return fail('Пароль — минимум 4 символа');
const key = name.toLowerCase();
const hash = await hashPassword(key, pass);
if (db[key]) {
if (db[key].hash !== hash) return fail('Неверный пароль');
toast(`С возвращением, ${db[key].name}!`, 'success');
} else {
db[key] = { name, hash, wins: { player: 0, computer: 0 }, streak: 0, best: 0, history: [] };
storage.save(db);
toast('Аккаунт создан. Удачи на арене!', 'success');
}
login(key);
});
function login(key) {
state.userKey = key;
sessionStorage.setItem(SESSION_KEY, key);
const u = db[key];
el.user.textContent = u.name;
el.avatar.textContent = u.name[0].toUpperCase();
const hue = ([...key].reduce((a, ch) => a + ch.charCodeAt(0), 0) % 40) + 12;
el.avatar.style.background = `linear-gradient(145deg, hsl(${hue} 70% 72%), hsl(${hue} 60% 50%))`;
el.auth.classList.add('hidden');
el.game.classList.remove('hidden');
el.form.reset();
state.round = 1;
renderScore();
renderHistory();
renderLeaders();
resetRound(false);
}
el.logout.addEventListener('click', () => {
state.token++;
state.userKey = null;
sessionStorage.removeItem(SESSION_KEY);
el.game.classList.add('hidden');
el.auth.classList.remove('hidden');
toast('Вы вышли из аккаунта');
});
el.togglePass.addEventListener('click', () => {
const show = el.password.type === 'password';
el.password.type = show ? 'text' : 'password';
el.togglePass.innerHTML = `<i class="fa-solid fa-eye${show ? '-slash' : ''}"></i>`;
});
/* ================= СОБЫТИЯ ================= */
el.roll.addEventListener('click', playerRoll);
el.stop.addEventListener('click', playerStop);
el.next.addEventListener('click', () => { sfx.click(); resetRound(); });
el.sound.addEventListener('click', () => {
soundOn = !soundOn;
localStorage.setItem(SOUND_KEY, soundOn ? 'on' : 'off');
renderSoundBtn();
sfx.click();
toast(soundOn ? 'Звук включён' : 'Звук выключен');
});
const openRules = () => el.rulesModal.classList.remove('hidden');
const closeRules = () => el.rulesModal.classList.add('hidden');
el.rules.addEventListener('click', openRules);
el.rulesClose.addEventListener('click', closeRules);
el.rulesModal.addEventListener('click', (e) => { if (e.target === el.rulesModal) closeRules(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') return closeRules();
if (el.game.classList.contains('hidden') || e.target.matches('input')) return;
if (!el.rulesModal.classList.contains('hidden')) return;
if (e.code === 'Space') {
e.preventDefault();
playerRoll();
} else if (e.key === 'Enter') {
e.preventDefault();
if (state.phase === 'over') { sfx.click(); resetRound(); }
else playerStop();
}
});
/* ================= СТАРТ ================= */
buildCube();
renderSoundBtn();
const saved = sessionStorage.getItem(SESSION_KEY);
if (saved && db[saved]) login(saved);