47c7f17c90
— RPU v2: бюджет удачи, потолок (ceiling), комбек после проигрышей, hot/cold анализ, интеграция с promo-фазами — Bank API: mock-банк для карты (депозит/вывод с балансом 1000₽) — Promo-сервис: автогенерация промокодов каждый час, типы (card_to_site, luck_boost, ceiling_boost, free_case, reset_streak) — Promo-фазы: LUCKY/NEUTRAL/UNLUCKY, переключение каждые 1-6ч — Admin: история транзакций (user_history), управление промокодами, управление promo-фазами, API фильтров/коллекций, RPU info — Database: WAL mode, новые поля RPU v2, TransactionLog, карточный баланс, статистики (streaks, ceiling, budget) — Frontend: /deposit, /promo/activate, /withdraw, /card-balance, /online-count, /rpu-info, lifespan вместо startup event — UI: user_history.html, улучшения профиля, кейсов, навигации — Cases: обновление cases.json (1892 строки новых данных) — Achievements: кэш коллекций, оптимизация is_custom проверок — Sounds: дополнительные звуковые эффекты — Upgrade: интеграция с RPU (множители, streaks) — .gitignore: исключены .db, .bak, __pycache__
1905 lines
90 KiB
HTML
1905 lines
90 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>{{ 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-secondary);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.card-front .card-price {
|
||
font-size: 9px;
|
||
color: var(--success-color);
|
||
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-secondary);
|
||
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: #ef4444;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.no-topdrop-message p {
|
||
color: var(--text-secondary);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* Адаптация для мобильных */
|
||
@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);
|
||
}
|
||
}
|
||
|
||
@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;
|
||
}
|
||
|
||
.count-buttons {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.count-btn {
|
||
padding: 6px 12px;
|
||
background: var(--bg-dark);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-primary);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
transition: all 0.2s;
|
||
min-width: 40px;
|
||
}
|
||
|
||
.count-btn.active {
|
||
background: var(--primary-color);
|
||
border-color: var(--primary-color);
|
||
}
|
||
|
||
.count-btn.farm-mode {
|
||
background: linear-gradient(135deg, #2a1a4e, #1a0a2e);
|
||
border-color: #ffd700;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{% include "_nav.html" %}
|
||
|
||
<main class="case-detail-container">
|
||
<div class="container">
|
||
<a href="/cases" class="back-link">← Назад к списку кейсов</a>
|
||
|
||
<div class="case-header">
|
||
<div class="case-title-row">
|
||
<div>
|
||
<h1>📦 {{ case_display_name }}</h1>
|
||
<p class="case-subtitle">{{ total_items }} предметов в кейсе</p>
|
||
</div>
|
||
<div class="case-info-badge">
|
||
<span class="case-price-tag">🔑 {{ case_price|money }} ₽ за шт.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="opening-controls">
|
||
{% if user %}
|
||
<div class="count-selector">
|
||
<label>Открыть:</label>
|
||
<div class="count-buttons" id="countButtons"></div>
|
||
</div>
|
||
<div class="total-price-display" id="totalPriceDisplay">
|
||
<span>Итого:</span> {{ case_price|money }} ₽
|
||
</div>
|
||
<button class="btn btn-primary btn-large" onclick="startOpening()" id="openButton">
|
||
🎲 Открыть
|
||
</button>
|
||
<button class="btn btn-fast" onclick="startOpening(true)" id="fastOpenButton" title="Быстрое открытие без анимации">⚡</button>
|
||
{% else %}
|
||
<div style="display:flex;align-items:center;gap:1rem;width:100%;justify-content:center;">
|
||
<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>
|
||
|
||
<div class="case-opening-section">
|
||
<!-- Для слот-машины — всегда видна -->
|
||
<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="case-items-section">
|
||
<h2>📋 Содержимое кейса ({{ total_items }} предметов)</h2>
|
||
<div class="rarity-filters">
|
||
<button class="filter-btn active" onclick="filterItems('all')">Все</button>
|
||
{% for rarity in sorted_rarities %}
|
||
<button class="filter-btn" 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" data-rarity="{{ rarity }}">
|
||
<h3 style="color: var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }}); border-bottom-color: var(--rarity-{{ rarity|lower|replace(' ', '-')|replace(' grade', '') }});">
|
||
{{ rarity }}
|
||
<span class="rarity-count">({{ items_by_rarity[rarity]|length }})</span>
|
||
</h3>
|
||
|
||
<div class="items-grid-compact">
|
||
{% for item in items_by_rarity[rarity] %}
|
||
<div class="item-card-compact" style="border-left-color: var(--rarity-{{ item.rarity|lower|replace(' ', '-')|replace(' grade', '') }});">
|
||
<img src="{{ item.image_url or '/static/placeholder.png' }}"
|
||
alt="{{ item.name }}"
|
||
onerror="this.src='/static/placeholder.png'">
|
||
<div class="item-name-compact" title="{{ item.name }}">{{ item.name }}</div>
|
||
<div class="item-meta-compact">
|
||
<span>{{ item.type }}</span>
|
||
<span title="Шанс">{{ "%.2f"|format(item.probability) }}%</span>
|
||
</div>
|
||
<div class="item-meta-compact" style="margin-top: 3px;">
|
||
<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="item-price-compact">
|
||
{% if item.min_price == item.max_price %}
|
||
{{ item.min_price|money }} ₽
|
||
{% else %}
|
||
{{ item.min_price|money }} - {{ item.max_price|money }} ₽
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</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 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.innerHTML = `<span>Итого:</span> ${totalPrice.toLocaleString()} ₽`;
|
||
if (openButton) {
|
||
openButton.style.background = userBalance < totalPrice ? 'var(--danger-color)' : 'var(--primary-color)';
|
||
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;
|
||
const el = document.getElementById('siteBalanceDisplay');
|
||
if (el) {
|
||
el.innerHTML = Math.floor(data.balance).toLocaleString('ru') + ' ₽ <span style="font-size:0.6rem;opacity:0.5;margin-left:0.25rem">▼ депозит</span>';
|
||
}
|
||
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) {
|
||
Notify.error(`Недостаточно средств! Нужно: ${totalPrice.toLocaleString()} ₽, у вас: ${Math.floor(userBalance).toLocaleString()} ₽`);
|
||
isOpening = false;
|
||
openButton.disabled = false;
|
||
openButton.textContent = isSlotCase ? '🎰 Крутить' : '🎲 Открыть';
|
||
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 {
|
||
Notify.error(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 {
|
||
Notify.error(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 {
|
||
Notify.error(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) {
|
||
Notify.error(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);
|
||
Notify.error('Ошибка при открытии кейсов');
|
||
} 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.01);
|
||
if (invItem) {
|
||
inventoryIds.push(invItem.id);
|
||
}
|
||
}
|
||
|
||
if (inventoryIds.length > 0) {
|
||
const formData = new FormData();
|
||
formData.append('inventory_ids', JSON.stringify(inventoryIds));
|
||
|
||
const res = await fetch('/web/api/inventory/sell/bulk', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
if (!res.ok) {
|
||
await new Promise(r => setTimeout(r, 500));
|
||
const retry = await fetch('/web/api/inventory/items');
|
||
const items2 = await retry.json();
|
||
const retryIds = [];
|
||
for (const trashItem of trashItems) {
|
||
const invItem = items2.find(i => i.item_id === trashItem.id &&
|
||
Math.abs(i.float - trashItem.float) < 0.01);
|
||
if (invItem && !inventoryIds.includes(invItem.id)) {
|
||
retryIds.push(invItem.id);
|
||
}
|
||
}
|
||
if (retryIds.length > 0) {
|
||
const fd2 = new FormData();
|
||
fd2.append('inventory_ids', JSON.stringify(retryIds));
|
||
await fetch('/web/api/inventory/sell/bulk', { method: 'POST', body: fd2 });
|
||
}
|
||
}
|
||
}
|
||
} 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-color);">💰 Шлак (${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-secondary);">
|
||
💰 Шлак (${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) {
|
||
Notify.error('Нет предметов для продажи');
|
||
return;
|
||
}
|
||
|
||
const total = topDropItems.reduce((sum, i) => sum + (i.price || 100), 0);
|
||
Notify.confirm('Продать все топ-дропы за ' + total.toLocaleString() + ' ₽?', 'Продажа', async function(confirmed) {
|
||
if (!confirmed) 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) {
|
||
Notify.error('Ошибка соединения');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== ЭФФЕКТЫ =====
|
||
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}, ${item.float})">Продать</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, itemFloat) {
|
||
Notify.confirm('Продать этот предмет?', 'Продажа', async function(confirmed) {
|
||
if (!confirmed) return;
|
||
try {
|
||
const response = await fetch('/web/api/inventory/items');
|
||
const items = await response.json();
|
||
const inventoryItem = items.find(i => i.item_id === itemId &&
|
||
Math.abs(i.float - (itemFloat || 0)) < 0.001);
|
||
|
||
if (!inventoryItem) {
|
||
Notify.error('Предмет не найден в инвентаре');
|
||
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) {
|
||
Notify.success('Предмет продан за ' + data.sold_price.toFixed(0) + ' ₽');
|
||
await refreshUserBalance();
|
||
await refreshInventoryAjax();
|
||
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 {
|
||
Notify.error(data.error || 'Ошибка при продаже');
|
||
}
|
||
} catch (error) {
|
||
Notify.error('Ошибка соединения');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function sellAllResults() {
|
||
if (!window.lastOpenedItems || window.lastOpenedItems.length === 0) {
|
||
Notify.error('Нет предметов для продажи');
|
||
return;
|
||
}
|
||
|
||
const total = window.lastOpenedItems.reduce((sum, i) => sum + (i.price || 100), 0);
|
||
Notify.confirm('Продать все выпавшие предметы за ' + total.toLocaleString() + ' ₽?', 'Продажа', async function(confirmed) {
|
||
if (!confirmed) 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) {
|
||
Notify.error('Предметы не найдены в инвентаре');
|
||
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) {
|
||
Notify.success('Продано ' + data.sold_count + ' предметов за ' + data.total_received.toFixed(0) + ' ₽');
|
||
await refreshUserBalance();
|
||
await refreshInventoryAjax();
|
||
const resultsDiv = document.getElementById('openingResults');
|
||
resultsDiv.style.display = 'none';
|
||
resultsDiv.innerHTML = '';
|
||
window.lastOpenedItems = [];
|
||
} else {
|
||
Notify.error(data.error || 'Ошибка при продаже');
|
||
}
|
||
} catch (error) {
|
||
Notify.error('Ошибка соединения');
|
||
}
|
||
});
|
||
}
|
||
|
||
function filterItems(rarity) {
|
||
document.querySelectorAll('.filter-btn').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/notifications.js"></script>
|
||
<script src="/static/js/safemode.js"></script>
|
||
<script src="/static/js/app.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 || 'Достижение';
|
||
showAchievementPopup(title);
|
||
});
|
||
}
|
||
}
|
||
</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> |