Загрузка данных


'use strict';

/* ================= УТИЛИТЫ ================= */
const $ = (s) => document.querySelector(s);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);