Files
dodep-simulator/templates/case_detail.html
T

1867 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>{{ case_display_name }} — CS2 Simulator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
padding: 16px;
perspective: 1000px;
}
.card-flip {
aspect-ratio: 3/4;
cursor: pointer;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.card-flip.flipped {
transform: rotateY(180deg);
}
.card-front, .card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
box-sizing: border-box;
}
.card-front {
background: var(--bg-card);
border: 2px solid var(--border-color);
transform: rotateY(180deg);
}
.card-back {
background: linear-gradient(135deg, #2a1a4e 0%, #1a0a2e 100%);
border: 2px solid #8b7355;
transform: rotateY(0deg);
background-image:
repeating-linear-gradient(45deg, rgba(255,215,0,0.1) 0px, rgba(255,215,0,0.1) 2px,
transparent 2px, transparent 8px);
}
.card-back::before {
content: "?";
font-size: 48px;
font-weight: bold;
color: #ffd700;
text-shadow: 0 0 20px #ffd700;
opacity: 0.5;
}
.card-front img {
width: 100%;
height: 100px;
object-fit: contain;
margin-bottom: 4px;
}
.card-front .card-name {
font-size: 9px;
text-align: center;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
width: 100%;
margin-top: 4px;
}
.card-front .card-rarity {
font-size: 8px;
margin-top: 2px;
font-weight: bold;
}
.card-front .card-float {
font-size: 8px;
color: var(--text-dim);
margin-top: 2px;
}
.card-front .card-price {
font-size: 9px;
color: var(--success);
font-weight: bold;
margin-top: 4px;
}
.card-flip.top-drop .card-front {
border-color: #ffd700;
box-shadow: 0 0 30px rgba(255, 215, 0, 0.5);
}
.cards-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-card);
border-radius: 8px;
margin: 12px 0;
}
.cards-actions {
display: flex;
gap: 8px;
}
.flip-progress {
color: var(--text-dim);
font-size: 14px;
}
.no-topdrop-message {
text-align: center;
padding: 40px;
background: var(--bg-card);
border-radius: 12px;
margin: 20px 0;
}
.no-topdrop-message h3 {
color: var(--danger);
margin-bottom: 12px;
}
.no-topdrop-message p {
color: var(--text-dim);
margin-bottom: 20px;
}
.count-buttons {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.count-btn {
padding: 6px 12px;
background: var(--bg-dark);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
min-width: 40px;
font-family: var(--font-body);
}
.count-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #000;
}
.count-btn.farm-mode {
background: linear-gradient(135deg, #2a1a4e, #1a0a2e);
border-color: #ffd700;
}
.count-btn.farm-mode.active {
background: #ffd700;
color: #000;
border-color: #ffd700;
}
@keyframes cardAppear {
from { opacity: 0; transform: scale(0.8) rotateY(180deg); }
to { opacity: 1; transform: scale(1) rotateY(180deg); }
}
.card-flip.appear {
animation: cardAppear 0.4s ease-out;
}
@media (max-width: 768px) {
.cards-grid { grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 8px; }
.card-front img { height: 70px; }
.card-back::before { font-size: 32px; }
}
@media (max-width: 480px) {
.cards-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<nav class="navbar">
<div class="container">
<a href="/" class="logo">CS2 <span>Simulator</span></a>
<button class="mobile-toggle" id="mobileToggle"></button>
<div class="nav-links" id="navLinks">
{% if user %}
<a href="/cases" class="nav-link active">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</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-ghost" id="soundToggle" title="Звук">🔊</button>
<button onclick="toggleSafeMode()" class="btn btn-ghost" id="safeModeToggle" title="Режим записи">🎙️</button>
<button onclick="logout()" class="btn btn-ghost">Выйти</button>
{% else %}
<a href="/cases" class="nav-link active">Кейсы</a>
<a href="/contracts" class="nav-link">Контракты</a>
<a href="/upgrade" class="nav-link">Апгрейд</a>
<a href="/activity" class="nav-link">Лента</a>
<button onclick="toggleSafeMode()" class="btn btn-ghost" 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="section">
<div class="container">
<a href="/cases" class="text-dim text-sm">← Назад к списку кейсов</a>
<div class="section-header mt-4">
<div>
<h1 class="section-title">{{ case_display_name }}</h1>
<p class="section-subtitle">{{ total_items }} предметов в кейсе</p>
</div>
<span class="badge badge-yellow">🔑 {{ "%.0f"|format(case_price) }} ₽ за шт.</span>
</div>
<div class="card flex items-center justify-between gap-3 flex-wrap">
{% if user %}
<div class="flex items-center gap-2">
<span class="form-label" style="margin:0;">Открыть:</span>
<div class="count-buttons" id="countButtons"></div>
</div>
<div class="flex items-center gap-3">
<span class="display-font text-primary" id="totalPriceDisplay">{{ "%.0f"|format(case_price) }} ₽</span>
<button class="btn btn-primary btn-large" onclick="startOpening()" id="openButton">
🎲 Открыть
</button>
<button class="btn btn-icon" onclick="startOpening(true)" id="fastOpenButton" title="Быстрое открытие без анимации"></button>
</div>
{% else %}
<div class="flex items-center gap-4 w-full justify-center">
<span class="text-dim">🔒 Войдите в аккаунт, чтобы открывать кейсы</span>
<a href="/login" class="btn btn-primary">Войти</a>
<a href="/register" class="btn btn-outline">Регистрация</a>
</div>
{% endif %}
</div>
<div class="mt-4">
<div id="slotMachineContainer" style="display: none;">
<div class="slot-machine">
<div class="slot-reels">
<div class="slot-reel" id="slotReel0">
<div class="slot-track" id="slotTrack0"></div>
</div>
<div class="slot-reel" id="slotReel1">
<div class="slot-track" id="slotTrack1"></div>
</div>
<div class="slot-reel" id="slotReel2">
<div class="slot-track" id="slotTrack2"></div>
</div>
<div class="slot-win-line"></div>
</div>
<div class="slot-frame-glow" id="slotFrameGlow"></div>
</div>
<div class="slot-result-display" id="slotResultDisplay" style="display: none;"></div>
<div class="slot-legend" id="slotLegend">
<div class="slot-legend-header">
<span class="slot-legend-symbol"></span>
<span class="slot-legend-label">Активации</span>
<span class="slot-legend-symbol"></span>
</div>
<div class="slot-legend-grid">
<div class="slot-legend-card" data-act="extra_spin">
<span class="slc-icon" style="color:#8b5cf6;">🔄</span>
<span class="slc-name">Бонус-вращение</span>
<span class="slc-desc">Один барабан крутится заново</span>
</div>
<div class="slot-legend-card" data-act="double">
<span class="slc-icon" style="color:#f59e0b;">×2</span>
<span class="slc-name">Удвоение</span>
<span class="slc-desc">При совпадении приз ×2</span>
</div>
<div class="slot-legend-card" data-act="quintuple">
<span class="slc-icon" style="color:#ef4444;">×5</span>
<span class="slc-name">Квинтупль</span>
<span class="slc-desc">При совпадении приз ×5</span>
</div>
<div class="slot-legend-card" data-act="guaranteed">
<span class="slc-icon" style="color:#3b82f6;">🏷️</span>
<span class="slc-name">Гарант</span>
<span class="slc-desc">Предмет даже при провале</span>
</div>
<div class="slot-legend-card" data-act="free_spin">
<span class="slc-icon" style="color:#10b981;">🆓</span>
<span class="slc-name">Фриспин</span>
<span class="slc-desc">Следующая попытка бесплатно</span>
</div>
</div>
</div>
</div>
<div id="openingAnimation" class="opening-animation-container" style="display: none;">
<div id="singleScrollContainer" style="display: none;">
<div class="scroll-container-full" id="singleScrollContainerInner">
<div class="center-indicator-full"></div>
<div class="scroll-track-full" id="singleScrollTrack"></div>
</div>
</div>
<div id="multiScrollContainer" class="multi-scroll-grid" style="display: none;"></div>
<div id="farmCardsContainer" style="display: none;"></div>
</div>
<div id="openingResults" style="display: none;"></div>
</div>
<div class="section-header mt-4">
<h2 class="section-title">📋 Содержимое кейса ({{ total_items }} предметов)</h2>
</div>
<div class="filter-bar">
<button class="filter-chip active" onclick="filterItems('all')">Все</button>
{% for rarity in sorted_rarities %}
<button class="filter-chip" onclick="filterItems('{{ rarity }}')"
style="color: var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }});">
{{ rarity }}
</button>
{% endfor %}
</div>
{% for rarity in sorted_rarities %}
<div class="rarity-section mb-4" data-rarity="{{ rarity }}">
<div class="flex items-center gap-2 mb-2">
<h3 class="display-font" style="color: var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }}); margin:0;">
{{ rarity }}
</h3>
<span class="badge" style="background:var(--bg-card);color:var(--text-dim);">({{ items_by_rarity[rarity]|length }})</span>
</div>
<div class="grid grid-auto-sm">
{% for item in items_by_rarity[rarity] %}
<div class="card" style="border-left: 3px solid var(--rarity-{{ item.rarity|lower|replace(' ', '-')|replace(' grade', '') }});">
<div class="flex flex-col items-center">
<img src="{{ item.image_url or '/static/placeholder.png' }}"
alt="{{ item.name }}"
onerror="this.src='/static/placeholder.png'"
style="width:100%;height:100px;object-fit:contain;margin-bottom:6px;">
<div class="truncate text-sm mb-2" title="{{ item.name }}" style="width:100%;">{{ item.name }}</div>
<div class="flex justify-between w-full text-xs text-dim">
<span>{{ item.type }}</span>
<span title="Шанс">{{ "%.2f"|format(item.probability) }}%</span>
</div>
<div class="flex w-full text-xs text-dim mt-1">
<span title="Float диапазон">Float: {{ "%.2f"|format(item.min_float) }} - {{ "%.2f"|format(item.max_float) }}</span>
</div>
{% if item.min_price and item.max_price %}
<div class="display-font text-primary text-sm mt-2">
{% if item.min_price == item.max_price %}
{{ "%.0f"|format(item.min_price) }} ₽
{% else %}
{{ "%.0f"|format(item.min_price) }} - {{ "%.0f"|format(item.max_price) }} ₽
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</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 currentOpenCount = 1;
let isOpening = false;
let caseItems = {{ case_items|tojson }};
let caseName = "{{ case_name }}";
let casePrice = {{ case_price }};
let userBalance = {{ user.balance }};
window.lastOpenedItems = [];
let freeSpinActive = false;
// Флаг фарм-кейса / слот-машины
const isFarmCase = caseName.toLowerCase().includes('фарм') ||
caseName.toLowerCase().includes('farm');
const isSlotCase = caseName.toLowerCase().includes('slot') ||
caseName.toLowerCase().includes('слот');
const maxCount = isSlotCase ? 1 : (isFarmCase ? 100 : 10);
// Порог для топ-дропа (минимум 2× цена кейса)
const TOP_DROP_THRESHOLD = Math.max(casePrice * 2, 5000);
// Для фарм-кейсов
let allFarmResults = [];
let topDropItems = [];
let trashItems = [];
let revealedCards = 0;
const RARITY_COLORS = {
'consumer': '#b0b0b0',
'industrial': '#5e98d9',
'mil-spec': '#4b69ff',
'restricted': '#8847ff',
'classified': '#d32ce6',
'covert': '#eb4b4b',
'rare': '#ffd700',
'extraordinary': '#ffd700',
'contraband': '#ffaa00'
};
function getRarityColor(rarity) {
if (!rarity) return '#b0b0b0';
let key = rarity.toLowerCase()
.replace(/ /g, '-')
.replace(/'/g, '')
.replace('grade', '')
.replace(/-$/, '')
.split('-')[0];
return RARITY_COLORS[key] || '#b0b0b0';
}
function initCountButtons() {
const container = document.getElementById('countButtons');
let counts = [];
if (isSlotCase) {
counts = [1];
} else if (isFarmCase) {
counts = [1, 5, 10, 50, 100];
} else {
counts = Array.from({length: 10}, (_, i) => i + 1);
}
container.innerHTML = counts.map(count =>
`<button onclick="setOpenCount(${count})"
class="count-btn ${isFarmCase ? 'farm-mode' : ''} ${count === 1 ? 'active' : ''}"
data-count="${count}">${count}</button>`
).join('');
}
function getItemDimensions(isMini) {
const container = isMini ?
document.querySelector('.scroll-container-mini') :
document.querySelector('.scroll-container-full');
if (!container) {
const isMobile = window.innerWidth <= 480;
if (isMini) {
return { itemWidth: isMobile ? 66 : 82, margin: isMobile ? 4 : 6 };
} else {
return { itemWidth: isMobile ? 100 : 136, margin: isMobile ? 5 : 8 };
}
}
const item = container.querySelector(isMini ? '.scroll-item-mini' : '.scroll-item-full');
if (!item) return { itemWidth: isMini ? 82 : 136, margin: isMini ? 6 : 8 };
const itemRect = item.getBoundingClientRect();
const computedStyle = window.getComputedStyle(item);
const marginLeft = parseFloat(computedStyle.marginLeft) || 0;
const marginRight = parseFloat(computedStyle.marginRight) || 0;
return {
itemWidth: itemRect.width + marginLeft + marginRight,
margin: marginLeft
};
}
function updatePriceDisplay() {
const totalPrice = casePrice * currentOpenCount;
const priceElement = document.getElementById('totalPriceDisplay');
const openButton = document.getElementById('openButton');
if (priceElement) priceElement.textContent = `${totalPrice.toLocaleString()}`;
if (openButton) {
openButton.style.background = userBalance < totalPrice ? 'var(--danger)' : '';
openButton.title = userBalance < totalPrice ? 'Недостаточно средств' : '';
}
}
async function refreshUserBalance() {
try {
const response = await fetch('/web/api/user/balance');
const data = await response.json();
if (data.success) {
userBalance = data.balance;
document.querySelectorAll('.user-balance').forEach(el => {
el.textContent = `${Math.floor(data.balance)}`;
});
updatePriceDisplay();
}
} catch (e) {
console.error('Ошибка обновления баланса:', e);
}
}
async function refreshInventoryAjax() {
try {
const resp = await fetch('/web/api/inventory/items');
const serverItems = await resp.json();
window._inventoryItems = serverItems;
} catch (e) {
console.error('Failed to refresh inventory:', e);
}
}
function resetCaseUI() {
const openButton = document.getElementById('openButton');
const animationDiv = document.getElementById('openingAnimation');
const resultsDiv = document.getElementById('openingResults');
const slotResultDisplay = document.getElementById('slotResultDisplay');
openButton.disabled = false;
openButton.textContent = isSlotCase ? '🎰 Открыть' : '🎲 Открыть';
animationDiv.style.display = 'none';
resultsDiv.style.display = 'none';
resultsDiv.innerHTML = '';
if (slotResultDisplay) {
slotResultDisplay.style.display = 'none';
slotResultDisplay.innerHTML = '';
}
isOpening = false;
freeSpinActive = false;
updatePriceDisplay();
}
function setOpenCount(count) {
currentOpenCount = count;
document.querySelectorAll('.count-btn').forEach(btn => {
btn.classList.remove('active');
if (parseInt(btn.dataset.count) === count) btn.classList.add('active');
});
updatePriceDisplay();
}
async function startOpening(fast) {
if (isOpening) return;
const totalPrice = casePrice * currentOpenCount;
SoundManager.click();
const hasFreeSpin = freeSpinActive;
freeSpinActive = false;
if (!hasFreeSpin && userBalance < totalPrice) {
alert(`Недостаточно средств!\nНужно: ${totalPrice.toLocaleString()}\nУ вас: ${Math.floor(userBalance).toLocaleString()}`);
return;
}
isOpening = true;
const openButton = document.getElementById('openButton');
const animationDiv = document.getElementById('openingAnimation');
const resultsDiv = document.getElementById('openingResults');
const singleContainer = document.getElementById('singleScrollContainer');
const multiContainer = document.getElementById('multiScrollContainer');
const farmCardsContainer = document.getElementById('farmCardsContainer');
const slotContainer = document.getElementById('slotMachineContainer');
openButton.disabled = true;
openButton.textContent = isSlotCase ? '🎰 Крутим...' : '🎲 Открываем...';
const fastBtn = document.getElementById('fastOpenButton');
if (fastBtn) fastBtn.disabled = true;
animationDiv.style.display = 'block';
resultsDiv.style.display = 'none';
resultsDiv.innerHTML = '';
// Скрываем все контейнеры
singleContainer.style.display = 'none';
multiContainer.style.display = 'none';
farmCardsContainer.style.display = 'none';
slotContainer.style.display = 'none';
try {
if (isSlotCase) {
// ===== СЛОТ-МАШИНА: 3 БАРАБАНА =====
slotContainer.style.display = 'block';
window.caseItems = caseItems;
const formData = new FormData();
formData.append('case_name', caseName);
if (hasFreeSpin) {
formData.append('free_spin', '1');
}
// Отправляем запрос на сервер (он вернёт 3 результата)
const response = await fetch('/web/api/cases/open/slot', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
handleAchievements(data);
SoundManager.slotSpin();
await animateSlots(data.reels, data.match, data.prize, data.bonus, data.spent, data.activations || [], data.multiplier || 0, data.multiplied_prize || null);
if (data.new_balance !== undefined) userBalance = data.new_balance;
await refreshUserBalance();
} else {
alert(data.error || 'Ошибка при открытии');
}
} else if (isFarmCase) {
// ===== ФАРМ-КЕЙС: КАРТОЧКИ С АВТОПРОДАЖЕЙ ШЛАКА =====
farmCardsContainer.style.display = 'block';
const formData = new FormData();
formData.append('case_name', caseName);
formData.append('count', currentOpenCount);
const response = await fetch('/web/api/cases/open/multiple', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
handleAchievements(data);
SoundManager.caseOpen();
allFarmResults = data.results;
window.lastOpenedItems = data.results;
// Разделяем на топ-дроп и шлак
separateItems();
// Автоматически продаём шлак
if (trashItems.length > 0) {
await sellTrashItems();
}
// Показываем результат
displayFarmResult();
if (data.new_balance !== undefined) {
userBalance = data.new_balance;
}
await refreshUserBalance();
} else {
alert(data.error || 'Ошибка при открытии');
}
} else if (fast) {
// ===== БЫСТРОЕ ОТКРЫТИЕ БЕЗ АНИМАЦИИ =====
const formData = new FormData();
formData.append('case_name', caseName);
formData.append('count', String(currentOpenCount));
const response = await fetch('/web/api/cases/open/multiple', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
handleAchievements(data);
SoundManager.caseOpen();
window.lastOpenedItems = data.results;
showResults(data.results);
if (data.new_balance !== undefined) userBalance = data.new_balance;
await refreshUserBalance();
} else {
alert(data.error || 'Ошибка при открытии');
}
} else {
// ===== ОБЫЧНЫЙ КЕЙС: СКРОЛЛЫ (РУЛЕТКА) =====
if (currentOpenCount === 1) {
singleContainer.style.display = 'block';
// Add highlight ring
let ring = singleContainer.querySelector('.target-highlight-ring');
if (!ring) {
ring = document.createElement('div');
ring.className = 'target-highlight-ring';
singleContainer.appendChild(ring);
}
const track = document.getElementById('singleScrollTrack');
SoundManager.caseOpen();
const result = await animateSingleScroll(track);
if (result) {
window.lastOpenedItems = result.results;
showResults(result.results);
if (result.new_balance !== undefined) userBalance = result.new_balance;
}
} else {
multiContainer.style.display = 'grid';
let cols;
const screenWidth = window.innerWidth;
if (screenWidth <= 480) cols = 2;
else if (screenWidth <= 768) cols = currentOpenCount <= 4 ? 2 : 3;
else cols = currentOpenCount <= 2 ? currentOpenCount : currentOpenCount <= 4 ? 2 : currentOpenCount <= 6 ? 3 : currentOpenCount <= 9 ? 3 : 5;
multiContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
multiContainer.innerHTML = '';
// ⚡ ОПТИМИЗАЦИЯ: 1 запрос на N открытий
const batchData = await (async () => {
const fd = new FormData();
fd.append('case_name', caseName);
fd.append('count', String(currentOpenCount));
const r = await fetch('/web/api/cases/open/multiple', { method: 'POST', body: fd });
return await r.json();
})();
if (!batchData.success) {
alert(batchData.error || 'Ошибка при открытии');
} else {
handleAchievements(batchData);
SoundManager.caseOpen();
const tracks = [];
for (let i = 0; i < currentOpenCount; i++) {
const wrapper = document.createElement('div');
wrapper.className = 'scroll-wrapper-full';
wrapper.innerHTML = `
<div class="scroll-container-mini">
<div class="center-indicator-mini"></div>
<div class="scroll-track-mini" id="track${i}"></div>
</div>
<div class="scroll-label">Кейс #${i + 1}</div>
`;
multiContainer.appendChild(wrapper);
tracks.push(wrapper.querySelector('.scroll-track-mini'));
}
const results = await Promise.all(tracks.map((track, i) => animateMiniScroll(track, i, batchData.results[i])));
const validResults = results.filter(r => r !== null);
if (validResults.length > 0) {
const allItems = validResults.flatMap(r => r.results);
window.lastOpenedItems = allItems;
showResults(allItems);
}
if (batchData.new_balance !== undefined) userBalance = batchData.new_balance;
}
}
await refreshUserBalance();
}
} catch (error) {
console.error('Error opening cases:', error);
alert('Ошибка при открытии кейсов');
} finally {
isOpening = false;
openButton.disabled = false;
openButton.textContent = isSlotCase ? '🎰 Открыть' : '🎲 Открыть';
const fastBtn = document.getElementById('fastOpenButton');
if (fastBtn) fastBtn.disabled = false;
if (!isFarmCase || (isFarmCase && topDropItems.length === 0)) {
// Не скрываем анимацию для фарм-кейса с сообщением "нет дропа"
animationDiv.style.display = 'none';
}
updatePriceDisplay();
}
}
// ===== СЛОТ-МАШИНА =====
function initSlotUI() {
if (!isSlotCase) return;
const slotContainer = document.getElementById('slotMachineContainer');
slotContainer.style.display = 'block';
// Hide count selector and fast button for slot
const countSelector = document.querySelector('.count-selector');
if (countSelector) countSelector.style.display = 'none';
const fastBtn = document.getElementById('fastOpenButton');
if (fastBtn) fastBtn.style.display = 'none';
// Change button text
const openButton = document.getElementById('openButton');
if (openButton) openButton.textContent = '🎰 Открыть';
// Fill reels with placeholder items
const placeholderItems = caseItems.slice(0, 9);
while (placeholderItems.length < 9) {
placeholderItems.push(caseItems[Math.floor(Math.random() * caseItems.length)]);
}
for (let i = 0; i < 3; i++) {
const track = document.getElementById(`slotTrack${i}`);
const chunk = placeholderItems.slice(i * 3, i * 3 + 3);
track.innerHTML = chunk.map((it, idx) => {
const rarityKey = (it.rarity || '').toLowerCase().replace(/ /g, '-').replace(/'/g, '').replace('grade', '').replace(/-$/, '');
const color = RARITY_COLORS[rarityKey] || '#b0b0b0';
return `<div class="slot-item" style="border-color: ${color}; opacity: 0.5;">
<img src="${it.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<span class="slot-item-name">${it.name || it.base_name || '???'}</span>
</div>`;
}).join('');
}
}
const ACTIVATION_DESC = {
'extra_spin': '🔄 Дополнительное вращение — один барабан крутится снова',
'double': '2x Удвоение приза — при совпадении приз ×2',
'quintuple': '5x Увеличение приза — при совпадении приз ×5',
'guaranteed': '🏷️ Гарантированный приз — получишь предмет даже при проигрыше',
'free_spin': '🆓 Бесплатное открытие — следующая попытка бесплатно'
};
function buildSlotItems(caseItems, serverItem, length, activation) {
const items = [];
for (let i = 0; i < length; i++) {
const ri = caseItems[Math.floor(Math.random() * caseItems.length)];
// Cosmetic badges for visual variety
let badge = '';
if (i !== length - 8 && Math.random() < 0.04) {
const cosmetic = ['extra_spin', 'guaranteed', 'free_spin'][Math.floor(Math.random() * 3)];
badge = cosmetic;
}
items.push({...ri, _badge: badge});
}
// Place target item near the end (position length-8) with its server activation
const targetPos = length - 8;
items[targetPos] = {...serverItem, _isTarget: true, _isTargetIdx: targetPos, _badge: activation || ''};
return items;
}
function renderSlotTrack(trackEl, items) {
trackEl.innerHTML = items.map((it, idx) => {
const rarityKey = (it.rarity || '').toLowerCase().replace(/ /g, '-').replace(/'/g, '').replace('grade', '').replace(/-$/, '');
const color = RARITY_COLORS[rarityKey] || '#b0b0b0';
const badge = it._badge;
const badgeHtml = badge ? `<span class="slot-badge slot-badge-${badge}" title="${ACTIVATION_DESC[badge] || ''}">${
{'extra_spin': '🔄', 'double': '2x', 'guaranteed': '🏷️', 'free_spin': '🆓', 'quintuple': '5x'}[badge] || ''
}</span>` : '';
return `<div class="slot-item" data-index="${idx}" style="border-color: ${color};">
${badgeHtml}
<img src="${it.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<span class="slot-item-name">${it.name || it.base_name || '???'}</span>
</div>`;
}).join('');
}
async function animateSlots(reelResults, match, prize, bonus, spent, activations, multiplier, multipliedPrize) {
const slotContainer = document.getElementById('slotMachineContainer');
const resultDisplay = document.getElementById('slotResultDisplay');
resultDisplay.style.display = 'none';
resultDisplay.innerHTML = '';
// Reset frame glow
const glow = document.getElementById('slotFrameGlow');
glow.style.borderColor = 'transparent';
glow.style.boxShadow = 'none';
// Speed lines
const speedLines = document.createElement('div');
speedLines.className = 'scroll-speed-lines';
slotContainer.querySelector('.slot-machine').appendChild(speedLines);
// Build reels — 80 items each, target at index 72
const caseItems = window.caseItems || [];
const REEL_ITEMS = 80;
const TARGET_POS = 72;
const ITEM_H = 80;
const WIN_Y = 80; // top of item when item center is at reel center (120 - 40)
const reelItems = [];
for (let i = 0; i < 3; i++) {
reelItems.push(buildSlotItems(caseItems, reelResults[i], REEL_ITEMS, activations[i] || ''));
const track = document.getElementById(`slotTrack${i}`);
renderSlotTrack(track, reelItems[i]);
// Reset position
track.style.transition = 'none';
track.style.transform = 'translateY(0px)';
void track.offsetHeight;
}
// Final positions for each reel
const finalY = reelItems.map(items => -(TARGET_POS * ITEM_H) + WIN_Y);
// Stop timings: 3.5s, 5.25s, 7s
const reelStopTimes = [3500, 5250, 7000];
const reelDecelDurations = [800, 800, 800]; // snap duration
let reelStopped = [false, false, false];
let reelProgress = [0, 0, 0];
const TOTAL_TIME = 7000;
const startTime = performance.now();
let animId = null;
let resolveAnimation = null;
const animPromise = new Promise(resolve => { resolveAnimation = resolve; });
function slotEase(t) {
// Wind-up phase: 0-12% time → 0-3% progress (slow cocking)
if (t < 0.12) return (t / 0.12) * 0.03;
// Release phase: 12-25% time → 3-20% progress (sudden acceleration)
if (t < 0.25) {
const p = (t - 0.12) / 0.13;
return 0.03 + p * p * 0.17;
}
// Fast spin: 25-82% time → 20-90% progress
if (t < 0.82) {
const p = (t - 0.25) / 0.57;
return 0.20 + p * 0.70;
}
// Deceleration: 82-100% time → 90-100% progress
const p = (t - 0.82) / 0.18;
// Sharp ease-out with slight overshoot at end
const eased = 1 - Math.pow(1 - p, 4);
const overshoot = 1 + 0.03 * Math.sin(p * Math.PI) * (1 - p);
return 0.90 + 0.10 * eased * overshoot;
}
function tick() {
const elapsed = performance.now() - startTime;
const t = Math.min(elapsed / TOTAL_TIME, 1);
const progress = slotEase(t); // 0 → ~1
for (let i = 0; i < 3; i++) {
if (reelStopped[i]) continue;
if (elapsed >= reelStopTimes[i]) {
// Snap to final with CSS transition
reelStopped[i] = true;
const track = document.getElementById(`slotTrack${i}`);
track.style.transition = `transform ${reelDecelDurations[i]}ms cubic-bezier(0.05, 0.7, 0.2, 1.0)`;
track.style.transform = `translateY(${finalY[i]}px)`;
continue;
}
// During spin: progress drives position
const track = document.getElementById(`slotTrack${i}`);
const currentY = finalY[i] * progress;
track.style.transform = `translateY(${currentY}px)`;
}
if (t < 1) {
animId = requestAnimationFrame(tick);
} else {
// Ensure all reels are at final position
for (let i = 0; i < 3; i++) {
if (!reelStopped[i]) {
const track = document.getElementById(`slotTrack${i}`);
track.style.transition = `transform ${reelDecelDurations[i]}ms cubic-bezier(0.05, 0.7, 0.2, 1.0)`;
track.style.transform = `translateY(${finalY[i]}px)`;
reelStopped[i] = true;
}
}
onSpinComplete();
}
}
function onSpinComplete() {
// Remove speed lines
if (speedLines.parentNode) speedLines.parentNode.removeChild(speedLines);
// Clear transitions after snap
setTimeout(() => {
for (let i = 0; i < 3; i++) {
const track = document.getElementById(`slotTrack${i}`);
track.style.transition = 'none';
// Highlight target
const els = track.querySelectorAll('.slot-item');
if (els[TARGET_POS]) els[TARGET_POS].classList.add('slot-item-hit');
}
}, reelDecelDurations[2] + 50);
// Flash + particles
const rarityColors = ['gold', 'blue', 'purple', 'pink', 'red'];
const flashColor = rarityColors[Math.floor(Math.random() * rarityColors.length)];
flashBurst(slotContainer.querySelector('.slot-machine'), flashColor);
particleBurst(slotContainer.querySelector('.slot-machine'), flashColor, 30);
// Glow frame
const glow = document.getElementById('slotFrameGlow');
if (match) {
glow.style.borderColor = '#ffd700';
glow.style.boxShadow = '0 0 60px rgba(255,215,0,0.5), inset 0 0 60px rgba(255,215,0,0.15)';
} else {
glow.style.borderColor = '#ef4444';
glow.style.boxShadow = '0 0 40px rgba(239,68,68,0.3), inset 0 0 40px rgba(239,68,68,0.1)';
}
// Show result
setTimeout(() => {
if (match && prize) {
let items = [prize];
let winNote = 'Совпадение на всех барабанах!';
if (multiplier > 1 && multipliedPrize) {
items = [multipliedPrize];
winNote = `💥 Множитель x${multiplier}! Приз увеличен!`;
particleBurst(slotContainer.querySelector('.slot-machine'), 'gold', 60);
} else if (bonus) {
items.push(bonus);
}
window.lastOpenedItems = items;
showResults(items);
resultDisplay.style.display = 'block';
resultDisplay.innerHTML = `
<div class="slot-win-banner">
<div class="slot-win-title">${getRandomPhrase('win')}</div>
<div class="slot-win-subtitle">${winNote}</div>
</div>
`;
particleBurst(slotContainer.querySelector('.slot-machine'), 'gold', 40);
} else if (prize && !match) {
// Guaranteed consolation
window.lastOpenedItems = [prize];
showResults([prize]);
resultDisplay.style.display = 'block';
resultDisplay.innerHTML = `
<div class="slot-win-banner" style="border-color: rgba(59,130,246,0.4);">
<div class="slot-win-title" style="color: #3b82f6;">🏷️ Гарантированный приз!</div>
<div class="slot-win-subtitle">Активация сработала — ты получил предмет!</div>
</div>
`;
particleBurst(slotContainer.querySelector('.slot-machine'), '#3b82f6', 30);
} else {
resultDisplay.style.display = 'block';
resultDisplay.innerHTML = `
<div class="slot-lose-banner">
<div class="slot-lose-title">${getRandomPhrase('lose')}</div>
<div class="slot-lose-subtitle">Повезёт в следующий раз!</div>
</div>
`;
}
// Check for free spin activation
if (!match && activations.includes('free_spin')) {
freeSpinActive = true;
const openButton = document.getElementById('openButton');
openButton.disabled = false;
openButton.textContent = '🆓 Бесплатно!';
}
slotContainer.querySelector('.slot-machine').appendChild(resultDisplay);
refreshUserBalance();
if (resolveAnimation) resolveAnimation();
}, 600);
}
animId = requestAnimationFrame(tick);
return animPromise;
}
// ===== ФУНКЦИИ ДЛЯ ФАРМ-КЕЙСОВ =====
function separateItems() {
topDropItems = [];
trashItems = [];
for (const item of allFarmResults) {
if (item.price > TOP_DROP_THRESHOLD) {
topDropItems.push(item);
} else {
trashItems.push(item);
}
}
}
async function sellTrashItems() {
try {
const response = await fetch('/web/api/inventory/items');
const items = await response.json();
const inventoryIds = [];
for (const trashItem of trashItems) {
const invItem = items.find(i => i.item_id === trashItem.id &&
Math.abs(i.float - trashItem.float) < 0.001);
if (invItem) {
inventoryIds.push(invItem.id);
}
}
if (inventoryIds.length > 0) {
const formData = new FormData();
formData.append('inventory_ids', JSON.stringify(inventoryIds));
await fetch('/web/api/inventory/sell/bulk', {
method: 'POST',
body: formData
});
}
} catch (e) {
console.error('Ошибка продажи шлака:', e);
}
}
function displayFarmResult() {
const farmContainer = document.getElementById('farmCardsContainer');
if (topDropItems.length === 0) {
farmContainer.innerHTML = `
<div class="no-topdrop-message">
<h3>😕 Увы, топ-дропа не оказалось</h3>
<p>Открыто ${currentOpenCount} кейсов, но ничего ценного не выпало.</p>
<p style="color: var(--success);">💰 Шлак (${trashItems.length} предметов) автоматически продан.</p>
<button class="btn btn-primary" onclick="tryAgain()">🎲 Попробовать ещё</button>
<button class="btn btn-outline" onclick="resetCaseUI()">↻ Вернуться</button>
</div>
`;
} else {
revealedCards = 0;
let html = `
<div class="cards-summary">
<span class="flip-progress" id="flipProgress">0/${topDropItems.length} топ-дропов</span>
<div class="cards-actions">
<button class="btn btn-success btn-sm" onclick="revealAllCards()">✨ Открыть все</button>
<button class="btn btn-outline btn-sm" onclick="resetCardsView()">↻ Сбросить</button>
</div>
</div>
<div class="cards-grid" id="cardsGrid"></div>
<div style="margin-top: 16px; text-align: center; color: var(--text-dim);">
💰 Шлак (${trashItems.length} предметов) автоматически продан
</div>
`;
farmContainer.innerHTML = html;
const grid = document.getElementById('cardsGrid');
topDropItems.forEach((item, index) => {
const color = getRarityColor(item.rarity);
const card = document.createElement('div');
card.className = 'card-flip top-drop appear';
card.dataset.index = index;
card.dataset.revealed = 'false';
card.innerHTML = `
<div class="card-front" style="border-color: ${color}; box-shadow: 0 0 20px ${color}40;">
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="card-name" title="${item.name}">${item.name}</div>
<div class="card-rarity" style="color: ${color};">${item.rarity}</div>
<div class="card-float">Float: ${item.float.toFixed(6)}</div>
<div class="card-price">💰 ${Math.floor(item.price).toLocaleString()} ₽</div>
</div>
<div class="card-back"></div>
`;
card.onclick = () => flipCard(card, index);
grid.appendChild(card);
});
}
}
function flipCard(card, index) {
if (card.classList.contains('flipped')) return;
if (card.dataset.revealed === 'true') return;
card.classList.add('flipped');
card.dataset.revealed = 'true';
revealedCards++;
// Particle burst on flip
const rarity = card.querySelector('.card-rarity')?.textContent || '';
const burst = document.createElement('div');
burst.className = 'particle-burst';
burst.style.inset = '-20px';
card.appendChild(burst);
const colors = ['#ffd700', '#f59e0b', '#fff'];
for (let i = 0; i < 8; i++) {
const pt = document.createElement('div');
pt.className = 'pt';
pt.style.setProperty('--px', (Math.random() - 0.5) * 100 + 'px');
pt.style.setProperty('--py', -(Math.random() * 80 + 10) + 'px');
pt.style.background = colors[Math.floor(Math.random() * colors.length)];
pt.style.left = '40%';
pt.style.top = '50%';
pt.style.animationDelay = Math.random() * 0.1 + 's';
pt.style.width = (2 + Math.random() * 3) + 'px';
pt.style.height = pt.style.width;
burst.appendChild(pt);
}
setTimeout(() => burst.remove(), 1000);
updateFlipProgress();
if (revealedCards === topDropItems.length) {
setTimeout(() => {
if (topDropItems.length > 0) {
showFarmResults(topDropItems);
}
}, 500);
}
}
function revealAllCards() {
const cards = document.querySelectorAll('.card-flip');
cards.forEach((card, index) => {
if (card.dataset.revealed === 'false') {
setTimeout(() => {
card.classList.add('flipped');
card.dataset.revealed = 'true';
}, index * 100);
}
});
revealedCards = topDropItems.length;
updateFlipProgress();
setTimeout(() => {
if (topDropItems.length > 0) {
showFarmResults(topDropItems);
}
}, topDropItems.length * 100 + 500);
}
function resetCardsView() {
const cards = document.querySelectorAll('.card-flip');
cards.forEach(card => {
card.classList.remove('flipped');
card.dataset.revealed = 'false';
});
revealedCards = 0;
updateFlipProgress();
}
function updateFlipProgress() {
const progressEl = document.getElementById('flipProgress');
if (progressEl) {
progressEl.textContent = `${revealedCards}/${topDropItems.length} топ-дропов`;
}
}
function tryAgain() {
const animationDiv = document.getElementById('openingAnimation');
animationDiv.style.display = 'none';
startOpening();
}
function showFarmResults(results) {
const resultsDiv = document.getElementById('openingResults');
const animationDiv = document.getElementById('openingAnimation');
animationDiv.style.display = 'none';
const grouped = {};
results.forEach((item) => {
const key = item.name;
if (!grouped[key]) {
grouped[key] = { ...item, count: 1 };
} else {
grouped[key].count++;
}
});
let totalValue = results.reduce((sum, item) => sum + (item.price || 100), 0);
let html = `
<div class="case-results-header">
<div class="case-results-title">🎉 Топ-дропы (${results.length})</div>
<div class="case-results-actions">
<button class="btn btn-success btn-sm" onclick="sellAllTopDrops()">
💰 Продать всё (${totalValue.toLocaleString()} ₽)
</button>
<button class="btn btn-outline btn-sm" onclick="resetCaseUI()">↻ Ещё</button>
</div>
</div>
<div class="results-grid-enhanced">
`;
Object.values(grouped).forEach((item, idx) => {
const color = getRarityColor(item.rarity);
html += `
<div class="result-card-enhanced" style="animation-delay: ${idx * 0.06}s; --glow: ${color};">
<div class="result-card-glow" style="background: radial-gradient(ellipse at center, ${color}20, transparent 70%);"></div>
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="result-card-body">
<div class="result-name-enhanced" title="${item.name}">${item.name}</div>
<div class="result-meta-enhanced">
<span style="color: ${color};">${item.rarity}</span>
</div>
<div class="result-footer-enhanced">
<span class="result-price-enhanced">${(item.price || 100).toLocaleString()} ₽</span>
</div>
</div>
${item.count > 1 ? `<span class="result-count-badge">x${item.count}</span>` : ''}
</div>
`;
});
html += `</div>`;
resultsDiv.innerHTML = html;
resultsDiv.style.display = 'block';
}
async function sellAllTopDrops() {
if (topDropItems.length === 0) {
alert('Нет предметов для продажи');
return;
}
const total = topDropItems.reduce((sum, i) => sum + (i.price || 100), 0);
if (!confirm(`Продать все топ-дропы за ${total.toLocaleString()} ₽?`)) return;
try {
const response = await fetch('/web/api/inventory/items');
const items = await response.json();
const inventoryIds = [];
for (const item of topDropItems) {
const invItem = items.find(i => i.item_id === item.id &&
Math.abs(i.float - item.float) < 0.001);
if (invItem) {
inventoryIds.push(invItem.id);
}
}
if (inventoryIds.length > 0) {
const formData = new FormData();
formData.append('inventory_ids', JSON.stringify(inventoryIds));
await fetch('/web/api/inventory/sell/bulk', {
method: 'POST',
body: formData
});
}
await refreshUserBalance();
await refreshInventoryAjax();
topDropItems = [];
resetCaseUI();
} catch (error) {
alert('Ошибка соединения');
}
}
// ===== ЭФФЕКТЫ =====
function addSpeedLines(container) {
const el = document.createElement('div');
el.className = 'scroll-speed-lines';
el.id = 'speedLines_' + Math.random().toString(36).slice(2);
container.appendChild(el);
return el;
}
function removeSpeedLines(el) {
if (el && el.parentNode) el.parentNode.removeChild(el);
}
function flashBurst(container, rarity) {
const flash = document.createElement('div');
flash.className = 'scroll-flash burst';
const r = (rarity || '').toLowerCase();
if (r.includes('covert') || r.includes('red')) flash.classList.add('red');
else if (r.includes('classified') || r.includes('pink')) flash.classList.add('pink');
else if (r.includes('restricted') || r.includes('purple')) flash.classList.add('purple');
else if (r.includes('mil-spec') || r.includes('blue')) flash.classList.add('blue');
else flash.classList.add('gold');
container.appendChild(flash);
requestAnimationFrame(() => {
flash.style.opacity = '1';
setTimeout(() => {
flash.style.transition = 'opacity 0.4s';
flash.style.opacity = '0';
setTimeout(() => flash.remove(), 500);
}, 200);
});
}
function particleBurst(container, rarity, count) {
const burst = document.createElement('div');
burst.className = 'particle-burst';
container.appendChild(burst);
const r = (rarity || '').toLowerCase();
let colors = ['#ffd700', '#f59e0b'];
if (r.includes('covert') || r.includes('red')) colors = ['#eb4b4b', '#ff6b6b', '#ffd700'];
else if (r.includes('classified') || r.includes('pink')) colors = ['#d32ce6', '#f59e0b', '#fff'];
else if (r.includes('restricted') || r.includes('purple')) colors = ['#8847ff', '#f59e0b', '#fff'];
else if (r.includes('mil-spec') || r.includes('blue')) colors = ['#4b69ff', '#f59e0b'];
else if (r.includes('industrial')) colors = ['#5e98d9', '#fff'];
count = count || (r.includes('covert') || r.includes('classified') || r.includes('extraordinary') ? 30 : 16);
for (let i = 0; i < count; i++) {
const pt = document.createElement('div');
pt.className = 'pt';
pt.style.setProperty('--px', (Math.random() - 0.5) * 200 + 'px');
pt.style.setProperty('--py', -(Math.random() * 150 + 20) + 'px');
pt.style.background = colors[Math.floor(Math.random() * colors.length)];
pt.style.left = (40 + Math.random() * 20) + '%';
pt.style.top = (40 + Math.random() * 20) + '%';
pt.style.animationDelay = Math.random() * 0.2 + 's';
pt.style.width = (2 + Math.random() * 4) + 'px';
pt.style.height = pt.style.width;
pt.style.borderRadius = Math.random() > 0.5 ? '50%' : '2px';
burst.appendChild(pt);
}
setTimeout(() => burst.remove(), 1500);
}
// ===== ФУНКЦИИ ДЛЯ ОБЫЧНЫХ КЕЙСОВ (СКРОЛЛЫ) =====
function animateSingleScroll(track) {
return new Promise((resolve) => {
const formData = new FormData();
formData.append('case_name', caseName);
formData.append('count', '1');
fetch('/web/api/cases/open/multiple', {
method: 'POST',
body: formData
}).then(r => r.json()).then(data => {
if (!data.success) { resolve(null); return; }
const targetItem = data.results[0];
const items = [];
const totalItems = 32;
const targetPos = 14;
for (let i = 0; i < totalItems; i++) {
if (i === targetPos) items.push(targetItem);
else items.push(caseItems[Math.floor(Math.random() * caseItems.length)]);
}
track.innerHTML = '';
items.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'scroll-item-full';
if (idx === targetPos) div.classList.add('target-item');
const color = getRarityColor(item.rarity);
div.style.borderColor = color;
div.style.borderWidth = '2px';
div.style.borderStyle = 'solid';
div.innerHTML = `
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="scroll-item-name-full" title="${item.name}">${item.name.substring(0, 25)}</div>
<div class="scroll-item-rarity-full" style="color: ${color};">${item.rarity}</div>
`;
track.appendChild(div);
});
const container = track.parentElement;
const containerWidth = container.offsetWidth;
const dims = getItemDimensions(false);
const itemTotalWidth = dims.itemWidth;
const targetOffset = (containerWidth / 2) - (itemTotalWidth / 2);
const finalPos = -(targetPos * itemTotalWidth) + targetOffset;
let currentPos = containerWidth + 100;
track.style.transform = `translateX(${currentPos}px)`;
track.style.willChange = 'transform';
// Speed lines
const speedLines = addSpeedLines(container);
const duration = 7500;
const startTime = performance.now();
// Звук прокрутки: тик на каждом предмете
let lastTickIndex = -1;
function animate() {
const now = performance.now();
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const prevPos = currentPos;
currentPos = (containerWidth + 100) - ((containerWidth + 100) - finalPos) * easeOut;
track.style.transform = `translateX(${currentPos}px)`;
// Звук: определяем какой предмет сейчас в центре
const centerX = -currentPos + targetOffset + itemTotalWidth / 2;
const tickIndex = Math.floor(centerX / itemTotalWidth);
if (tickIndex >= 0 && tickIndex < totalItems && tickIndex !== lastTickIndex) {
SoundManager.wheelSpin();
lastTickIndex = tickIndex;
}
if (progress < 1) {
requestAnimationFrame(animate);
} else {
track.style.transform = `translateX(${finalPos}px)`;
track.style.willChange = 'auto';
removeSpeedLines(speedLines);
// Финальный звук
const hasRare = ['Covert', 'Extraordinary', 'Rare Special Item'].includes(targetItem.rarity);
const hasMid = ['Classified', 'Restricted'].includes(targetItem.rarity);
if (hasRare) SoundManager.caseCovert();
else if (hasMid) SoundManager.caseRare();
// Flash burst
flashBurst(container, targetItem.rarity);
particleBurst(container, targetItem.rarity, 24);
const targetElement = track.querySelector('.target-item');
if (targetElement) {
targetElement.style.transition = 'all 0.3s ease';
targetElement.style.transform = 'scale(1.08)';
targetElement.style.boxShadow = `0 0 40px ${getRarityColor(targetItem.rarity)}, 0 0 80px ${getRarityColor(targetItem.rarity)}40`;
targetElement.style.zIndex = '100';
targetElement.style.borderWidth = '3px';
}
setTimeout(() => resolve(data), 800);
}
}
requestAnimationFrame(animate);
}).catch((err) => {
console.error('Single scroll error:', err);
resolve(null);
});
});
}
function animateMiniScroll(track, index, preloadedResult) {
return new Promise((resolve) => {
if (preloadedResult) {
// Используем предзагруженный результат (оптимизация)
doAnimate({ success: true, results: [preloadedResult], new_balance: 0 });
return;
}
const formData = new FormData();
formData.append('case_name', caseName);
formData.append('count', '1');
fetch('/web/api/cases/open/multiple', {
method: 'POST',
body: formData
}).then(r => r.json()).then(data => doAnimate(data)).catch(err => {
console.error('Mini scroll error:', err);
resolve(null);
});
function doAnimate(data) {
if (!data || !data.success) { resolve(null); return; }
const targetItem = data.results[0];
const items = [];
const totalItems = 28;
const targetPos = 11;
for (let i = 0; i < totalItems; i++) {
if (i === targetPos) items.push(targetItem);
else items.push(caseItems[Math.floor(Math.random() * caseItems.length)]);
}
track.innerHTML = '';
items.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'scroll-item-mini';
if (idx === targetPos) div.classList.add('target-mini-item');
const color = getRarityColor(item.rarity);
div.style.borderColor = color;
div.style.borderWidth = '2px';
div.style.borderStyle = 'solid';
div.innerHTML = `
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="scroll-item-name-mini" title="${item.name}">${item.name.substring(0, 12)}</div>
`;
track.appendChild(div);
});
const container = track.parentElement;
const containerWidth = container.offsetWidth;
const dims = getItemDimensions(true);
const itemTotalWidth = dims.itemWidth;
const targetOffset = (containerWidth / 2) - (itemTotalWidth / 2);
const finalPos = -(targetPos * itemTotalWidth) + targetOffset;
let currentPos = containerWidth + 50;
track.style.transform = `translateX(${currentPos}px)`;
track.style.willChange = 'transform';
// Staggered start
const staggerDelay = (index || 0) * 120;
const speedLines = addSpeedLines(container);
const duration = 6000 + Math.random() * 1500;
const startTime = performance.now() + staggerDelay;
let started = false;
function animate() {
const now = performance.now();
const elapsed = now - startTime;
if (elapsed < 0) {
requestAnimationFrame(animate);
return;
}
if (!started) {
started = true;
}
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
currentPos = (containerWidth + 50) - ((containerWidth + 50) - finalPos) * easeOut;
track.style.transform = `translateX(${currentPos}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
track.style.transform = `translateX(${finalPos}px)`;
track.style.willChange = 'auto';
removeSpeedLines(speedLines);
// Effects (lighter for mini)
flashBurst(container, targetItem.rarity);
particleBurst(container, targetItem.rarity, 10);
const targetElement = track.querySelector('.target-mini-item');
if (targetElement) {
targetElement.style.transition = 'all 0.3s ease';
targetElement.style.transform = 'scale(1.1)';
targetElement.style.boxShadow = `0 0 25px ${getRarityColor(targetItem.rarity)}`;
targetElement.style.zIndex = '100';
}
setTimeout(() => resolve(data), 400);
}
}
requestAnimationFrame(animate);
}
});
}
function showResults(results) {
const resultsDiv = document.getElementById('openingResults');
// Звук в зависимости от редкости
const rarities = results.map(r => r.rarity || '');
const hasRare = rarities.some(r => r === 'Covert' || r === 'Extraordinary' || r === 'Rare Special Item');
const hasRestricted = rarities.some(r => r === 'Classified' || r === 'Restricted');
if (hasRare) {
SoundManager.caseCovert();
} else if (hasRestricted) {
SoundManager.caseRare();
}
const grouped = {};
results.forEach((item) => {
const key = item.name;
if (!grouped[key]) {
grouped[key] = { ...item, count: 1 };
} else {
grouped[key].count++;
}
});
let totalValue = results.reduce((sum, item) => sum + (item.price || 100), 0);
let html = `
<div class="case-results-header">
<div class="case-results-title">🎉 Получено (${results.length})</div>
<div class="case-results-actions">
<button class="btn btn-success btn-sm" onclick="sellAllResults()">
💰 Продать всё (${totalValue.toLocaleString()} ₽)
</button>
<button class="btn btn-outline btn-sm" onclick="resetCaseUI()">↻ Ещё</button>
</div>
</div>
<div class="results-grid-enhanced" id="resultsGridEnhanced">
`;
Object.values(grouped).forEach((item, idx) => {
const color = getRarityColor(item.rarity);
html += `
<div class="result-card-enhanced" style="animation-delay: ${idx * 0.06}s; --glow: ${color};">
<div class="result-card-glow" style="background: radial-gradient(ellipse at center, ${color}20, transparent 70%);"></div>
<img src="${item.image_url || '/static/placeholder.png'}" onerror="this.src='/static/placeholder.png'">
<div class="result-card-body">
<div class="result-name-enhanced" title="${item.name}">${item.name}</div>
<div class="result-meta-enhanced">
<span style="color: ${color};">${item.rarity}</span>
</div>
<div class="result-footer-enhanced">
<span class="result-price-enhanced">${(item.price || 100).toLocaleString()} ₽</span>
<button class="btn-sell-result" onclick="sellResultItem(${item.id})">Продать</button>
</div>
</div>
${item.count > 1 ? `<span class="result-count-badge">x${item.count}</span>` : ''}
</div>
`;
});
html += `</div>`;
resultsDiv.innerHTML = html;
resultsDiv.style.display = 'block';
// Stagger appear animation
document.querySelectorAll('.result-card-enhanced').forEach((card, i) => {
card.style.animation = 'none';
card.offsetHeight;
card.style.animation = `fadeInUp 0.4s ease-out forwards`;
card.style.animationDelay = `${i * 0.06}s`;
});
}
async function sellResultItem(itemId) {
if (!confirm('Продать этот предмет?')) return;
try {
const response = await fetch('/web/api/inventory/items');
const items = await response.json();
const inventoryItem = items.find(i => i.item_id === itemId);
if (!inventoryItem) {
alert('Предмет не найден в инвентаре');
return;
}
const formData = new FormData();
formData.append('inventory_id', inventoryItem.id);
const sellResponse = await fetch('/web/api/inventory/sell', {
method: 'POST',
body: formData
});
const data = await sellResponse.json();
if (data.success) {
await refreshUserBalance();
await refreshInventoryAjax();
// Remove the sold item card from display
const cards = document.querySelectorAll('.result-card-enhanced');
for (const card of cards) {
const btn = card.querySelector('.btn-sell-result');
if (btn && btn.getAttribute('onclick')?.includes(itemId)) {
card.style.transition = 'all 0.3s ease';
card.style.opacity = '0';
card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300);
break;
}
}
} else {
alert(data.error || 'Ошибка при продаже');
}
} catch (error) {
alert('Ошибка соединения');
}
}
async function sellAllResults() {
if (!window.lastOpenedItems || window.lastOpenedItems.length === 0) {
alert('Нет предметов для продажи');
return;
}
const total = window.lastOpenedItems.reduce((sum, i) => sum + (i.price || 100), 0);
if (!confirm(`Продать все выпавшие предметы за ${total.toLocaleString()} ₽?`)) return;
try {
const response = await fetch('/web/api/inventory/items');
const items = await response.json();
const inventoryIds = [];
for (const resultItem of window.lastOpenedItems) {
const invItem = items.find(i => i.item_id === resultItem.id &&
Math.abs(i.float - resultItem.float) < 0.001);
if (invItem) {
inventoryIds.push(invItem.id);
}
}
if (inventoryIds.length === 0) {
alert('Предметы не найдены в инвентаре');
return;
}
const formData = new FormData();
formData.append('inventory_ids', JSON.stringify(inventoryIds));
const sellResponse = await fetch('/web/api/inventory/sell/bulk', {
method: 'POST',
body: formData
});
const data = await sellResponse.json();
if (data.success) {
await refreshUserBalance();
await refreshInventoryAjax();
// Clear results and reset UI
const resultsDiv = document.getElementById('openingResults');
resultsDiv.style.display = 'none';
resultsDiv.innerHTML = '';
window.lastOpenedItems = [];
} else {
alert(data.error || 'Ошибка при продаже');
}
} catch (error) {
alert('Ошибка соединения');
}
}
function filterItems(rarity) {
document.querySelectorAll('.filter-chip').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('.rarity-section').forEach(section => {
section.style.display = (rarity === 'all' || section.dataset.rarity === rarity) ? 'block' : 'none';
});
}
async function logout() {
await fetch('/web/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}
window.addEventListener('resize', () => {
if (!isOpening) {
updatePriceDisplay();
}
});
document.addEventListener('DOMContentLoaded', () => {
initCountButtons();
updatePriceDisplay();
initSlotUI();
// Hide fast button for farm cases
if (isFarmCase) {
const fastBtn = document.getElementById('fastOpenButton');
if (fastBtn) fastBtn.style.display = 'none';
}
});
</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>