825 lines
42 KiB
HTML
825 lines
42 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||
<title>Апгрейд — CS2 Simulator</title>
|
||
<link rel="stylesheet" href="/static/css/style.css">
|
||
</head>
|
||
<body class="upgrade-page">
|
||
<nav class="navbar">
|
||
<div class="container">
|
||
<a href="/" class="logo">🎮 CS2 Simulator</a>
|
||
<div class="nav-links">
|
||
{% if user %}
|
||
<a href="/cases" class="nav-link">Кейсы</a>
|
||
<a href="/contracts" class="nav-link">Контракты</a>
|
||
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
|
||
<a href="/crash" class="nav-link">💥 Crash</a>
|
||
<a href="/profile" class="nav-link">Профиль</a>
|
||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||
<a href="/achievements" class="nav-link">🏆 Достижения</a>
|
||
<a href="/profile" class="nav-link">Профиль</a>
|
||
<span class="user-balance">{{ "%.0f"|format(user.balance) }} ₽</span>
|
||
<button onclick="toggleSound()" class="btn btn-outline" id="soundToggle" title="Звук">🔊</button>
|
||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||
<button onclick="logout()" class="btn btn-outline">Выйти</button>
|
||
{% else %}
|
||
<a href="/cases" class="nav-link">Кейсы</a>
|
||
<a href="/contracts" class="nav-link">Контракты</a>
|
||
<a href="/upgrade" class="nav-link active">🆙 Апгрейд</a>
|
||
<a href="/activity" class="nav-link">📰 Лента</a>
|
||
<button onclick="toggleSafeMode()" class="btn btn-outline" id="safeModeToggle" title="Режим записи">🎙️</button>
|
||
<a href="/login" class="btn btn-outline">Войти</a>
|
||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="upgrade-container">
|
||
<div class="container">
|
||
<div class="upgrade-header">
|
||
<h1>🆙 Апгрейд</h1>
|
||
<p>Рискни предметом ради более дорогого</p>
|
||
</div>
|
||
|
||
<div class="upgrade-layout">
|
||
<!-- Inventory -->
|
||
<div class="inventory-panel">
|
||
<div class="panel-header">
|
||
<div class="panel-title">
|
||
<span>📦 Инвентарь</span>
|
||
<span style="font-size: 0.65rem; color: #6b6b7a;">{{ inventory_items|length }}</span>
|
||
</div>
|
||
<div class="inv-filter-bar">
|
||
<input type="text" class="inv-search-input" id="invSearch" placeholder="Поиск..." oninput="onInvSearch()">
|
||
<div class="inv-sort-group">
|
||
<button class="inv-sort-btn" onclick="setInvSort('default', this)">📋</button>
|
||
<button class="inv-sort-btn" onclick="setInvSort('price_asc', this)">💰↑</button>
|
||
<button class="inv-sort-btn" onclick="setInvSort('price_desc', this)">💰↓</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="inventory-list" id="inventoryList"></div>
|
||
</div>
|
||
|
||
<!-- Wheel -->
|
||
<div class="wheel-panel" style="position: relative;">
|
||
<div class="selected-items-display">
|
||
<div class="selected-item-mini" id="selectedInputDisplay">
|
||
<div class="item-frame">
|
||
<img src="/static/placeholder.png" id="displayInputImage">
|
||
</div>
|
||
<div class="selected-item-mini-name" id="displayInputName">Не выбрано</div>
|
||
<div class="selected-item-mini-price" id="displayInputPrice">—</div>
|
||
</div>
|
||
<div class="arrow-icon">➡</div>
|
||
<div class="selected-item-mini" id="selectedTargetDisplay">
|
||
<div class="item-frame">
|
||
<img src="/static/placeholder.png" id="displayTargetImage">
|
||
</div>
|
||
<div class="selected-item-mini-name" id="displayTargetName">Не выбрано</div>
|
||
<div class="selected-item-mini-price" id="displayTargetPrice">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wheel-container">
|
||
<div class="wheel-outer-ring"></div>
|
||
<div class="wheel" id="upgradeWheel" style="--probability: 0;"></div>
|
||
<div class="wheel-ticks"></div>
|
||
<div class="wheel-pointer-container" id="pointerContainer">
|
||
<div class="wheel-pointer"></div>
|
||
</div>
|
||
<div class="wheel-center" id="wheelProbability">0%</div>
|
||
</div>
|
||
|
||
<div class="upgrade-actions">
|
||
{% if user %}
|
||
<button class="upgrade-btn" onclick="executeUpgrade()" id="upgradeBtn" disabled>
|
||
🎲 Апгрейд
|
||
</button>
|
||
<button class="quick-btn" id="quickModeBtn" onclick="toggleQuickMode()" title="Быстрый режим">⚡</button>
|
||
{% else %}
|
||
<div style="display:flex;align-items:center;gap:1rem;width:100%;justify-content:center;padding:1rem;">
|
||
<span style="color:var(--text-secondary);">🔒 Войдите, чтобы совершать апгрейды</span>
|
||
<a href="/login" class="btn btn-primary">Войти</a>
|
||
<a href="/register" class="btn btn-outline">Регистрация</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div id="toastContainer" class="toast-container"></div>
|
||
</div>
|
||
|
||
<!-- Target -->
|
||
<div class="target-panel">
|
||
<div class="target-header">
|
||
<div class="panel-title" style="margin-bottom: 0.4rem;">
|
||
<span>🎯 Цель</span>
|
||
</div>
|
||
<input type="text" class="target-search-input" id="targetSearch" placeholder="Поиск..." oninput="searchTargetItems()">
|
||
<div class="sort-controls">
|
||
<button class="sort-btn" onclick="setTargetSort('closest', this)">🎯 Ближайшие</button>
|
||
<button class="sort-btn" onclick="setTargetSort('price_desc', this)">💎 Дорогие</button>
|
||
<button class="sort-btn" onclick="setTargetSort('price_asc', this)">💰 Дешёвые</button>
|
||
<button class="sort-btn" onclick="setTargetSort('name', this)">📋 Имя</button>
|
||
</div>
|
||
<div class="price-filter-bar" id="priceFilterBar" style="display: none;">
|
||
<span style="font-size:0.55rem;color:#4a4a5a;text-transform:uppercase;letter-spacing:0.04em;padding:0.2rem 0;margin-right:0.15rem;">Шанс</span>
|
||
<button class="pf-btn" onclick="setPriceFilter('probhigh', this)">75%</button>
|
||
<button class="pf-btn" onclick="setPriceFilter('probmid', this)">50%</button>
|
||
<button class="pf-btn" onclick="setPriceFilter('probalow', this)">25%</button>
|
||
<button class="pf-btn" onclick="setPriceFilter('proba10', this)">10%</button>
|
||
<span style="font-size:0.55rem;color:#4a4a5a;text-transform:uppercase;letter-spacing:0.04em;padding:0.2rem 0;margin:0 0.15rem;">×</span>
|
||
<button class="pf-btn" onclick="setPriceFilter('2', this)">2×</button>
|
||
<button class="pf-btn" onclick="setPriceFilter('4', this)">4×</button>
|
||
<button class="pf-btn" onclick="setPriceFilter('8', this)">8×</button>
|
||
<button class="pf-btn" onclick="setPriceFilter('10', this)">10×</button>
|
||
</div>
|
||
</div>
|
||
<div class="target-list" id="targetList">
|
||
<div class="empty-state">Выберите предмет</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- History -->
|
||
<div class="history-section">
|
||
<h3 class="history-title">📜 История <span class="history-count" id="historyCount">{{ recent_upgrades|length }}</span></h3>
|
||
<div class="history-grid" id="historyGrid"></div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// === Фразы для побед/поражений ===
|
||
const PHRASE_PARTS = {
|
||
win: {
|
||
pre: ['🎉','🔥','⭐','🏆','💎','✨','🎯','👑','💫','🌟','🎰','💪','🥇','🎊','🏅'],
|
||
verb: ['Победа','Джекпот','Успех','Красава','В дамках','Есть контакт','Повезло','Забрал','Выбил','Сорвал куш','Всё пучком','Лут','Топчик','Козырь','Фартануло'],
|
||
noun: ['! 🔥','! 💎','! 🏆','! 👑','! ✨','! 💪','!','!','!'],
|
||
},
|
||
lose: {
|
||
pre: ['😔','💔','😢','💀','😭','😤','😩','💸','😞','😓','🤡','💀','😅','🙃','🤷'],
|
||
verb: ['Не повезло','Облом','Мимо','Пролёт','Не выпал','Не срослось','Пусто','Мимо кассы','В следующий раз','Не фортануло','Повезёт потом','Ноль','Минус','Увы','Печаль'],
|
||
noun: [' 😔',' 💔','...',' 😢',' 😭',' 💸','...'],
|
||
}
|
||
};
|
||
let usedPhraseKeys = new Set();
|
||
function getRandomPhrase(type) {
|
||
const parts = PHRASE_PARTS[type];
|
||
for (let attempt = 0; attempt < 100; attempt++) {
|
||
const pre = parts.pre[Math.floor(Math.random() * parts.pre.length)];
|
||
const verb = parts.verb[Math.floor(Math.random() * parts.verb.length)];
|
||
const noun = parts.noun[Math.floor(Math.random() * parts.noun.length)];
|
||
const key = pre + '|' + verb + '|' + noun;
|
||
if (!usedPhraseKeys.has(key)) {
|
||
usedPhraseKeys.add(key);
|
||
return pre + ' ' + verb + noun;
|
||
}
|
||
}
|
||
usedPhraseKeys.clear();
|
||
return getRandomPhrase(type);
|
||
}
|
||
|
||
let selectedInput = null;
|
||
let selectedTarget = null;
|
||
let inventoryItems = {{ inventory_items|tojson }};
|
||
let allTargetItems = [];
|
||
let currentSearchQuery = '';
|
||
let currentSort = 'closest';
|
||
let currentPriceFilter = null;
|
||
let isSpinning = false;
|
||
let quickMode = false;
|
||
let invSearchQuery = '';
|
||
let invSort = 'default';
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadInitialTargets();
|
||
renderHistory({{ recent_upgrades|tojson }});
|
||
renderInventory();
|
||
});
|
||
|
||
function renderInventory() {
|
||
let items = [...inventoryItems];
|
||
|
||
if (invSearchQuery.length >= 1) {
|
||
const q = invSearchQuery.toLowerCase();
|
||
items = items.filter(i => i.name.toLowerCase().includes(q));
|
||
}
|
||
|
||
if (invSort === 'price_asc') {
|
||
items.sort((a, b) => a.price_rub - b.price_rub);
|
||
} else if (invSort === 'price_desc') {
|
||
items.sort((a, b) => b.price_rub - a.price_rub);
|
||
}
|
||
|
||
const container = document.getElementById('inventoryList');
|
||
if (items.length === 0) {
|
||
container.innerHTML = '<div class="empty-state" style="padding:1rem;text-align:center;color:var(--text-secondary)">Ничего не найдено</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = items.map(item => `
|
||
<div class="inventory-item-card ${selectedInput && selectedInput.inventory_id === item.id ? 'selected' : ''}"
|
||
onclick="selectInputItem(${item.id})" data-id="${item.id}" data-price="${item.price_rub}">
|
||
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
|
||
<div class="inventory-item-info">
|
||
<div class="inventory-item-name" title="${item.name}">${item.name}</div>
|
||
<div class="inventory-item-meta">
|
||
<span>${item.rarity}</span>
|
||
<span class="inventory-item-price">${Math.floor(item.price_rub).toLocaleString()} ₽</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function loadInitialTargets() {
|
||
try {
|
||
const response = await fetch(`/admin/api/items/search?q=&limit=600`);
|
||
allTargetItems = await response.json();
|
||
allTargetItems = allTargetItems.filter((item, index, self) =>
|
||
index === self.findIndex(t => t.id === item.id)
|
||
);
|
||
currentSort = 'closest';
|
||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
|
||
applySortAndFilter();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadTargetsForInput(inputPrice) {
|
||
try {
|
||
// Поиск только по цене — больше выбора и приколюх
|
||
const minP = Math.round(inputPrice * 1.05);
|
||
const maxP = Math.round(inputPrice * 19);
|
||
const resp = await fetch(`/admin/api/items/search?q=&min_price=${minP}&max_price=${maxP}&limit=600`);
|
||
const items = await resp.json();
|
||
allTargetItems = items.filter((item, index, self) =>
|
||
index === self.findIndex(t => t.id === item.id)
|
||
);
|
||
currentSort = 'closest';
|
||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
|
||
applySortAndFilter();
|
||
} catch (e) {
|
||
console.error('Failed to load targets:', e);
|
||
applySortAndFilter();
|
||
}
|
||
}
|
||
|
||
function selectInputItem(itemId) {
|
||
if (isSpinning) return;
|
||
const item = inventoryItems.find(i => i.id === itemId);
|
||
if (!item) return;
|
||
|
||
selectedInput = {
|
||
inventory_id: item.id,
|
||
item_id: item.item_id,
|
||
name: item.name,
|
||
price: item.price_rub,
|
||
image_url: item.image_url,
|
||
rarity: item.rarity
|
||
};
|
||
|
||
renderInventory();
|
||
document.getElementById('displayInputImage').src = item.image_url || '/static/placeholder.png';
|
||
document.getElementById('displayInputName').textContent = item.name.substring(0, 25);
|
||
document.getElementById('displayInputPrice').textContent = `${Math.floor(item.price_rub).toLocaleString()} ₽`;
|
||
document.getElementById('selectedInputDisplay').classList.add('selected');
|
||
|
||
document.getElementById('priceFilterBar').style.display = 'flex';
|
||
currentPriceFilter = null;
|
||
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
|
||
currentSort = 'closest';
|
||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelector('.sort-btn[onclick*="closest"]')?.classList.add('active');
|
||
|
||
loadTargetsForInput(item.price_rub);
|
||
updateProbability();
|
||
}
|
||
|
||
function applySortAndFilter() {
|
||
if (!allTargetItems.length) return;
|
||
|
||
let filtered = [...allTargetItems];
|
||
|
||
if (currentSearchQuery.length >= 2) {
|
||
filtered = filtered.filter(item =>
|
||
item.name.toLowerCase().includes(currentSearchQuery.toLowerCase())
|
||
);
|
||
}
|
||
|
||
if (selectedInput) {
|
||
const inputPrice = selectedInput.price;
|
||
filtered = filtered.filter(item => {
|
||
const price = item.price_rub || 100;
|
||
if (price <= inputPrice) return false;
|
||
const maxPrice = inputPrice * 19;
|
||
return price <= maxPrice;
|
||
});
|
||
|
||
if (currentPriceFilter) {
|
||
filtered = filtered.filter(item => {
|
||
const price = item.price_rub || 100;
|
||
const ratio = price / inputPrice;
|
||
const prob = (inputPrice / price) * 100;
|
||
switch (currentPriceFilter) {
|
||
case '2': return ratio >= 1.8 && ratio <= 2.5;
|
||
case '4': return ratio >= 3.5 && ratio <= 5;
|
||
case '8': return ratio >= 7 && ratio <= 10;
|
||
case '10': return ratio >= 8.5 && ratio <= 12;
|
||
case 'probalow': return prob >= 20 && prob <= 30;
|
||
case 'probmid': return prob >= 45 && prob <= 55;
|
||
case 'probhigh': return prob >= 70 && prob <= 75;
|
||
case 'proba10': return prob >= 8 && prob <= 12;
|
||
default: return true;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if (currentSort === 'closest' && selectedInput) {
|
||
const inputPrice = selectedInput.price;
|
||
filtered.sort((a, b) => {
|
||
const pa = (inputPrice / (a.price_rub || 100)) * 100;
|
||
const pb = (inputPrice / (b.price_rub || 100)) * 100;
|
||
const da = Math.abs(Math.min(75, pa) - 75);
|
||
const db = Math.abs(Math.min(75, pb) - 75);
|
||
if (da !== db) return da - db;
|
||
return (a.price_rub || 0) - (b.price_rub || 0);
|
||
});
|
||
} else if (currentSort === 'price_asc') {
|
||
filtered.sort((a, b) => (a.price_rub || 0) - (b.price_rub || 0));
|
||
} else if (currentSort === 'price_desc') {
|
||
filtered.sort((a, b) => (b.price_rub || 0) - (a.price_rub || 0));
|
||
} else {
|
||
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
displayTargetItems(filtered.slice(0, 120));
|
||
}
|
||
|
||
function setTargetSort(sort, btn) {
|
||
currentSort = sort;
|
||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
applySortAndFilter();
|
||
}
|
||
|
||
function setPriceFilter(mult, btn) {
|
||
currentPriceFilter = mult === currentPriceFilter ? null : mult;
|
||
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
|
||
if (currentPriceFilter && btn) btn.classList.add('active');
|
||
applySortAndFilter();
|
||
}
|
||
|
||
function onInvSearch() {
|
||
invSearchQuery = document.getElementById('invSearch').value;
|
||
renderInventory();
|
||
}
|
||
|
||
function setInvSort(sort, btn) {
|
||
invSort = sort;
|
||
document.querySelectorAll('.inv-sort-btn').forEach(b => b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
renderInventory();
|
||
}
|
||
|
||
function toggleQuickMode() {
|
||
quickMode = !quickMode;
|
||
document.getElementById('quickModeBtn').classList.toggle('active', quickMode);
|
||
}
|
||
|
||
async function searchTargetItems() {
|
||
const query = document.getElementById('targetSearch').value;
|
||
currentSearchQuery = query;
|
||
const container = document.getElementById('targetList');
|
||
|
||
if (query.length < 2) {
|
||
applySortAndFilter();
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = '<div class="empty-state">🔍 Поиск…</div>';
|
||
|
||
try {
|
||
const response = await fetch(`/admin/api/items/search?q=${encodeURIComponent(query)}&limit=600`);
|
||
const items = await response.json();
|
||
allTargetItems = items.filter((item, index, self) =>
|
||
index === self.findIndex(t => t.id === item.id)
|
||
);
|
||
applySortAndFilter();
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="empty-state">Ошибка</div>';
|
||
}
|
||
}
|
||
|
||
function displayTargetItems(items) {
|
||
const container = document.getElementById('targetList');
|
||
|
||
if (items.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">Ничего не найдено</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = items.map(item => {
|
||
const price = item.price_rub || 100;
|
||
const imageUrl = item.image_url || '/static/placeholder.png';
|
||
|
||
let isDisabled = false;
|
||
let disabledReason = '';
|
||
if (selectedInput) {
|
||
const minPrice = selectedInput.price * 0.5;
|
||
const maxPrice = selectedInput.price * 19;
|
||
if (price < minPrice) {
|
||
isDisabled = true;
|
||
disabledReason = 'Слишком дешёвый';
|
||
} else if (price > maxPrice) {
|
||
isDisabled = true;
|
||
disabledReason = 'Слишком дорогой';
|
||
}
|
||
}
|
||
|
||
const safeName = item.name.replace(/'/g, "\\'");
|
||
const safeUrl = imageUrl.replace(/'/g, "\\'");
|
||
|
||
return `
|
||
<div class="target-item-card ${isDisabled ? 'disabled' : ''}"
|
||
onclick="selectTargetItem(${item.id}, '${safeName}', ${price}, '${safeUrl}', '${item.rarity || 'Unknown'}')"
|
||
data-price="${price}"
|
||
title="${isDisabled ? disabledReason : ''}">
|
||
<img src="${imageUrl}" onerror="this.src='/static/placeholder.png'">
|
||
<div class="target-item-info">
|
||
<div class="target-item-name" title="${item.name}">${item.name}</div>
|
||
<div class="target-item-meta">
|
||
<span>${item.rarity || ''}</span>
|
||
<span class="target-item-price">${price.toLocaleString()} ₽</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
if (selectedTarget) {
|
||
document.querySelectorAll('.target-item-card').forEach(card => {
|
||
if (card.onclick && card.onclick.toString().includes(`selectTargetItem(${selectedTarget.item_id}`)) {
|
||
card.classList.add('selected');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function selectTargetItem(id, name, price, imageUrl, rarity) {
|
||
if (isSpinning) return;
|
||
const card = event.target.closest('.target-item-card');
|
||
if (card.classList.contains('disabled')) {
|
||
Notify.error('Этот предмет не подходит!');
|
||
return;
|
||
}
|
||
|
||
document.querySelectorAll('.target-item-card').forEach(c => c.classList.remove('selected'));
|
||
card.classList.add('selected');
|
||
|
||
selectedTarget = {
|
||
item_id: id,
|
||
name: name,
|
||
price: price,
|
||
image_url: imageUrl,
|
||
rarity: rarity
|
||
};
|
||
|
||
document.getElementById('displayTargetImage').src = imageUrl || '/static/placeholder.png';
|
||
document.getElementById('displayTargetName').textContent = name.substring(0, 25);
|
||
document.getElementById('displayTargetPrice').textContent = `${price.toLocaleString()} ₽`;
|
||
document.getElementById('selectedTargetDisplay').classList.add('selected');
|
||
|
||
updateProbability();
|
||
}
|
||
|
||
async function updateProbability() {
|
||
if (!selectedInput || !selectedTarget) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('input_inventory_id', selectedInput.inventory_id);
|
||
formData.append('target_item_id', selectedTarget.item_id);
|
||
|
||
try {
|
||
const response = await fetch('/web/api/upgrade/calculate', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const prob = data.adjusted_probability.toFixed(1);
|
||
document.getElementById('wheelProbability').textContent = `${prob}%`;
|
||
|
||
const wheel = document.getElementById('upgradeWheel');
|
||
wheel.style.setProperty('--probability', prob);
|
||
|
||
document.getElementById('upgradeBtn').disabled = false;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function sparkles(container) {
|
||
for (let i = 0; i < 24; i++) {
|
||
const s = document.createElement('div');
|
||
s.className = 'sparkle';
|
||
s.style.left = Math.random() * 100 + '%';
|
||
s.style.top = Math.random() * 100 + '%';
|
||
s.style.setProperty('--dx', (Math.random() - 0.5) * 140 + 'px');
|
||
s.style.setProperty('--dy', -(Math.random() * 120 + 50) + 'px');
|
||
s.style.animationDelay = Math.random() * 0.6 + 's';
|
||
s.style.width = (2 + Math.random() * 5) + 'px';
|
||
s.style.height = s.style.width;
|
||
s.style.background = ['#f59e0b', '#10b981', '#ffd700', '#fff', '#ff6b6b'][Math.floor(Math.random() * 5)];
|
||
container.appendChild(s);
|
||
}
|
||
}
|
||
|
||
async function refreshBalance() {
|
||
try {
|
||
const r = await fetch('/web/api/user/balance');
|
||
const d = await r.json();
|
||
if (d.success) {
|
||
document.querySelectorAll('.user-balance').forEach(el => {
|
||
el.textContent = `${Math.floor(d.balance).toLocaleString()} ₽`;
|
||
});
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function refreshHistory() {
|
||
try {
|
||
const r = await fetch('/web/api/upgrade/history');
|
||
const d = await r.json();
|
||
if (d.success) renderHistory(d.history);
|
||
} catch (e) {}
|
||
}
|
||
|
||
function renderHistory(items) {
|
||
const grid = document.getElementById('historyGrid');
|
||
const count = document.getElementById('historyCount');
|
||
if (count) count.textContent = items.length;
|
||
|
||
if (!items.length) {
|
||
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1;text-align:center;padding:1rem;color:var(--text-secondary)">История пуста</div>';
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = items.map(u => `
|
||
<div class="history-card ${u.success ? 'success' : 'fail'}">
|
||
${u.success
|
||
? `<img src="${u.input_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy">
|
||
<div class="history-info">
|
||
<div class="history-names">${(u.input_item_name || '').substring(0, 12)}… → ${(u.target_item_name || '').substring(0, 12)}…</div>
|
||
<div class="history-meta">
|
||
<span>${u.probability}%</span>
|
||
<span class="history-result success">✅</span>
|
||
</div>
|
||
</div>
|
||
<img src="${u.target_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="width:32px;height:28px;">`
|
||
: `<img src="${u.target_image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'" loading="lazy" style="opacity:0.5;">
|
||
<div class="history-info">
|
||
<div class="history-names">${(u.input_item_name || '').substring(0, 12)}… → ${(u.target_item_name || '').substring(0, 12)}…</div>
|
||
<div class="history-meta">
|
||
<span>${u.probability}%</span>
|
||
<span class="history-result fail">❌</span>
|
||
</div>
|
||
</div>`
|
||
}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function showToast(message, type) {
|
||
const container = document.getElementById('toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
requestAnimationFrame(() => toast.classList.add('show'));
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
async function executeUpgrade() {
|
||
if (!selectedInput || !selectedTarget || isSpinning) return;
|
||
|
||
// Lock the chosen items at spin start
|
||
const lockedInputId = selectedInput.inventory_id;
|
||
const lockedTargetId = selectedTarget.item_id;
|
||
const lockedPrice = selectedInput.price;
|
||
|
||
const formData = new FormData();
|
||
formData.append('input_inventory_id', lockedInputId);
|
||
formData.append('target_item_id', lockedTargetId);
|
||
formData.append('quick_mode', quickMode ? 'true' : 'false');
|
||
|
||
const btn = document.getElementById('upgradeBtn');
|
||
|
||
isSpinning = true;
|
||
btn.disabled = true;
|
||
btn.textContent = '🎰 Крутим…';
|
||
|
||
const pointer = document.getElementById('pointerContainer');
|
||
pointer.style.transition = 'none';
|
||
pointer.style.transform = 'rotate(0deg)';
|
||
void pointer.offsetHeight;
|
||
|
||
try {
|
||
const response = await fetch('/web/api/upgrade/execute', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (typeof handleAchievements === 'function') handleAchievements(data);
|
||
const spinDuration = quickMode ? 4 : 12;
|
||
pointer.style.transition = `transform ${spinDuration}s cubic-bezier(0.4, 0.0, 0.15, 1.0)`;
|
||
pointer.style.transform = `rotate(${data.final_angle}deg)`;
|
||
|
||
// Тики колеса: быстрая фаза каждые 15°, медленная (последние 90°) — 30°
|
||
let tickLastDeg = 0, tickCumulative = 0, tickNextThreshold = 15;
|
||
let tickLastTickCumulative = 0;
|
||
let tickControlPlayed = false;
|
||
const tickSlowStart = data.final_angle - 90;
|
||
const tickStartTime = performance.now();
|
||
const tickMaxDuration = spinDuration * 1000 + 500;
|
||
let tickRAF = null;
|
||
function tickLoop() {
|
||
const elapsed = performance.now() - tickStartTime;
|
||
if (elapsed > tickMaxDuration) return;
|
||
const style = window.getComputedStyle(pointer);
|
||
let deg = 0;
|
||
const tf = style.transform;
|
||
if (tf && tf !== 'none') {
|
||
const m = tf.match(/matrix\(([^)]+)\)/);
|
||
if (m) {
|
||
const v = m[1].split(', ').map(Number);
|
||
deg = Math.atan2(v[1], v[0]) * (180 / Math.PI);
|
||
} else {
|
||
const m2 = tf.match(/rotate\(([-\d.]+)deg\)/);
|
||
if (m2) deg = parseFloat(m2[1]);
|
||
}
|
||
}
|
||
let delta = deg - tickLastDeg;
|
||
if (delta > 180) delta -= 360;
|
||
if (delta < -180) delta += 360;
|
||
tickCumulative += delta;
|
||
tickLastDeg = deg;
|
||
if (tickCumulative >= tickNextThreshold) {
|
||
SoundManager.wheelSpin();
|
||
tickLastTickCumulative = tickCumulative;
|
||
tickNextThreshold += tickCumulative < tickSlowStart ? 15 : 30;
|
||
}
|
||
// Контрольный тик: если до финала <10°, а последний тик был >15° назад
|
||
const remaining = data.final_angle - tickCumulative;
|
||
const gap = tickCumulative - tickLastTickCumulative;
|
||
if (remaining < 10 && remaining > 0 && gap > 15 && !tickControlPlayed) {
|
||
SoundManager.wheelSpin();
|
||
tickControlPlayed = true;
|
||
}
|
||
tickRAF = requestAnimationFrame(tickLoop);
|
||
}
|
||
tickRAF = requestAnimationFrame(tickLoop);
|
||
|
||
let resultShown = false;
|
||
const onTransitionEnd = () => {
|
||
pointer.removeEventListener('transitionend', onTransitionEnd);
|
||
if (tickRAF) cancelAnimationFrame(tickRAF);
|
||
showResult();
|
||
};
|
||
pointer.addEventListener('transitionend', onTransitionEnd);
|
||
|
||
const showResult = () => {
|
||
if (resultShown) return;
|
||
resultShown = true;
|
||
if (data.upgrade_success && data.received_item) {
|
||
document.getElementById('displayTargetImage').src = data.received_item.image_url || '/static/placeholder.png';
|
||
document.getElementById('displayTargetName').textContent = data.received_item.name.substring(0, 25);
|
||
document.getElementById('displayTargetPrice').textContent = `${Math.floor(data.received_item.price).toLocaleString()} ₽`;
|
||
document.getElementById('selectedTargetDisplay').style.borderColor = '#10b981';
|
||
document.getElementById('selectedTargetDisplay').style.boxShadow = '0 0 20px rgba(16,185,129,0.3)';
|
||
showToast(`${getRandomPhrase('win')} ${data.received_item.name}`, 'success');
|
||
SoundManager.wheelWin();
|
||
} else {
|
||
document.getElementById('selectedInputDisplay').style.borderColor = '#ef4444';
|
||
document.getElementById('selectedInputDisplay').style.boxShadow = '0 0 20px rgba(239,68,68,0.3)';
|
||
showToast(getRandomPhrase('lose'), 'fail');
|
||
SoundManager.wheelLose();
|
||
}
|
||
|
||
refreshBalance();
|
||
|
||
setTimeout(() => {
|
||
document.getElementById('selectedInputDisplay').style.borderColor = '';
|
||
document.getElementById('selectedInputDisplay').style.boxShadow = '';
|
||
document.getElementById('selectedTargetDisplay').style.borderColor = '';
|
||
document.getElementById('selectedTargetDisplay').style.boxShadow = '';
|
||
|
||
inventoryItems = inventoryItems.filter(i => i.id !== lockedInputId);
|
||
selectedInput = null;
|
||
selectedTarget = null;
|
||
document.getElementById('selectedInputDisplay').classList.remove('selected');
|
||
document.getElementById('selectedTargetDisplay').classList.remove('selected');
|
||
document.getElementById('displayInputImage').src = '/static/placeholder.png';
|
||
document.getElementById('displayInputName').textContent = 'Не выбрано';
|
||
document.getElementById('displayInputPrice').textContent = '—';
|
||
document.getElementById('displayTargetImage').src = '/static/placeholder.png';
|
||
document.getElementById('displayTargetName').textContent = 'Не выбрано';
|
||
document.getElementById('displayTargetPrice').textContent = '—';
|
||
document.getElementById('wheelProbability').textContent = '0%';
|
||
document.getElementById('upgradeWheel').style.setProperty('--probability', '0');
|
||
btn.disabled = true;
|
||
btn.textContent = '🎲 Апгрейд';
|
||
isSpinning = false;
|
||
refreshInventoryAjax();
|
||
refreshHistory();
|
||
pointer.style.transition = 'none';
|
||
pointer.style.transform = 'rotate(0deg)';
|
||
}, 2000);
|
||
};
|
||
|
||
// Запасной таймер на случай если transitionend не сработает
|
||
setTimeout(showResult, spinDuration * 1000 + 300);
|
||
} else {
|
||
Notify.error(data.error || 'Ошибка');
|
||
isSpinning = false;
|
||
btn.disabled = false;
|
||
btn.textContent = '🎲 Апгрейд';
|
||
}
|
||
} catch (e) {
|
||
Notify.error('Ошибка соединения');
|
||
isSpinning = false;
|
||
btn.disabled = false;
|
||
btn.textContent = '🎲 Апгрейд';
|
||
}
|
||
}
|
||
|
||
async function refreshInventoryAjax() {
|
||
try {
|
||
const resp = await fetch('/web/api/inventory/items');
|
||
const serverItems = await resp.json();
|
||
// Enrich with price/image from server data
|
||
inventoryItems = serverItems.map(item => {
|
||
const img = item.image_url || (item.item_id ? `/static/placeholder.png` : '/static/placeholder.png');
|
||
return {
|
||
id: item.id,
|
||
item_id: item.item_id,
|
||
name: item.name,
|
||
rarity: item.rarity,
|
||
price_rub: item.price || item.price_rub || 100,
|
||
image_url: img,
|
||
wear: item.wear || '',
|
||
float: item.float || item.float_value || 0
|
||
};
|
||
});
|
||
renderInventory();
|
||
} catch (e) {
|
||
console.error('Failed to refresh inventory:', e);
|
||
renderInventory();
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
await fetch('/web/api/auth/logout', { method: 'POST' });
|
||
window.location.href = '/';
|
||
}
|
||
</script>
|
||
<script src="/static/js/sounds.js"></script>
|
||
<script src="/static/js/websocket.js"></script>
|
||
<script src="/static/js/notifications.js"></script>
|
||
<script src="/static/js/safemode.js"></script>
|
||
<script>
|
||
function handleAchievements(data) {
|
||
if (data && data.achievements_unlocked && Array.isArray(data.achievements_unlocked) && data.achievements_unlocked.length > 0) {
|
||
data.achievements_unlocked.forEach(ach => {
|
||
const title = ach.title || ach.name || 'Достижение';
|
||
const reward = ach.reward || '';
|
||
showAchievementPopup(title, reward);
|
||
});
|
||
}
|
||
}
|
||
</script>
|
||
<script>
|
||
function toggleSound() {
|
||
const enabled = SoundManager.toggle();
|
||
document.getElementById('soundToggle').textContent = enabled ? '🔊' : '🔇';
|
||
}
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const btn = document.getElementById('soundToggle');
|
||
if (btn) btn.textContent = localStorage.getItem('sound_enabled') !== 'false' ? '🔊' : '🔇';
|
||
});
|
||
</script>
|
||
{% if user %}{% include '_activity_sidebar.html' %}{% endif %}
|
||
</body>
|
||
</html> |