chore: initial commit — CS2 Simulator

This commit is contained in:
2026-07-05 10:40:00 +00:00
commit c268557997
40 changed files with 824236 additions and 0 deletions
+824
View File
@@ -0,0 +1,824 @@
<!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="/inventory" 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')) {
alert('Этот предмет не подходит!');
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 {
alert(data.error || 'Ошибка');
isSpinning = false;
btn.disabled = false;
btn.textContent = '🎲 Апгрейд';
}
} catch (e) {
alert('Ошибка соединения');
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/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>