feat(gameplay): add crazy dave casino and bank, and zombie mutation lab
This commit is contained in:
1 parent
d8be8f4366
commit
b8db5a123d
16 files changed
+2365
-135
No files matched your search
@@ -1090,6 +1090,113 @@ canvas#gameCanvas {
|
|||||||
background: #f44336;
|
background: #f44336;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Active Sessions and Devices Styling */
|
||||||
|
.auth-sessions-section {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sessions-list {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #81c784 rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sessions-list::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sessions-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #81c784;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 10px;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card.is-current {
|
||||||
|
border-color: #81c784;
|
||||||
|
background: rgba(76, 175, 80, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-dev-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffeb3b;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b0bec5;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-current-badge {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #a5d6a7;
|
||||||
|
background: rgba(46, 125, 50, 0.4);
|
||||||
|
border: 1px solid #66bb6a;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-terminate-session {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotating {
|
||||||
|
display: inline-block;
|
||||||
|
animation: spin 0.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Workshop / Garden Lab Styles */
|
/* Workshop / Garden Lab Styles */
|
||||||
.btn-workshop {
|
.btn-workshop {
|
||||||
background: linear-gradient(to bottom, #00897b, #004d40);
|
background: linear-gradient(to bottom, #00897b, #004d40);
|
||||||
@@ -2901,3 +3008,203 @@ body.theme-winter .banner-card {
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Crazy Dave's Casino & Bank Modal ─── */
|
||||||
|
.casino-card {
|
||||||
|
max-width: 680px;
|
||||||
|
width: 95%;
|
||||||
|
background: radial-gradient(circle at top, #2e1045 0%, #120726 60%, #080312 100%);
|
||||||
|
border: 3px solid #ffca28;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0,0,0,0.85), 0 0 24px rgba(255, 202, 40, 0.35);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-balance-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid rgba(255, 202, 40, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-tab {
|
||||||
|
background: #28143d;
|
||||||
|
color: #b0bec5;
|
||||||
|
border: 1px solid #7b1fa2;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-tab.active {
|
||||||
|
background: linear-gradient(135deg, #d81b60, #8e24aa);
|
||||||
|
color: #fff;
|
||||||
|
border-color: #ffca28;
|
||||||
|
box-shadow: 0 0 10px rgba(216, 27, 96, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine-cabinet {
|
||||||
|
background: #180928;
|
||||||
|
border: 2px solid #ba68c8;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: inset 0 0 18px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reels-frame {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 4px solid #ffd54f;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
box-shadow: inset 0 4px 8px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.5);
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel.spinning {
|
||||||
|
animation: reelShake 0.1s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reelShake {
|
||||||
|
0% { transform: translateY(-4px); }
|
||||||
|
100% { transform: translateY(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-result-banner {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffeb3b;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bet {
|
||||||
|
background: #371a52;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #9c27b0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bet.active {
|
||||||
|
background: #ffb300;
|
||||||
|
color: #000;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spin {
|
||||||
|
background: linear-gradient(135deg, #e91e63, #9c27b0);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #ffeb3b;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 14px rgba(233, 30, 99, 0.6);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spin:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-paytable {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cfd8dc;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.bank-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-box {
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 1px solid #546e7a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Zombie Lab Mutations (Workshop Tab 3) ─── */
|
||||||
|
.zombie-tier-card {
|
||||||
|
border-color: #ab47bc !important;
|
||||||
|
background: linear-gradient(135deg, rgba(74, 20, 140, 0.25) 0%, rgba(38, 50, 56, 0.4) 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zombie-tier-badge {
|
||||||
|
background: #7b1fa2 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+140
@@ -61,12 +61,14 @@
|
|||||||
<button id="btn-drawer-main-screen" class="drawer-item">🏠 Главный экран (Режимы)</button>
|
<button id="btn-drawer-main-screen" class="drawer-item">🏠 Главный экран (Режимы)</button>
|
||||||
<button id="btn-drawer-inventory" class="drawer-item">🎒 Выбор растений / Инвентарь</button>
|
<button id="btn-drawer-inventory" class="drawer-item">🎒 Выбор растений / Инвентарь</button>
|
||||||
<button id="btn-drawer-campaign" class="drawer-item">🗺️ Карта Кампании (25 уровней)</button>
|
<button id="btn-drawer-campaign" class="drawer-item">🗺️ Карта Кампании (25 уровней)</button>
|
||||||
|
<button id="btn-drawer-casino" class="drawer-item">🎰 Казино & Банк Дэйва</button>
|
||||||
<button id="btn-drawer-workshop" class="drawer-item">🧪 Сад-Мастерская (Крафт и перки)</button>
|
<button id="btn-drawer-workshop" class="drawer-item">🧪 Сад-Мастерская (Крафт и перки)</button>
|
||||||
<button id="btn-drawer-zengarden" class="drawer-item">🪴 Дзен-Сад (Пассивный доход)</button>
|
<button id="btn-drawer-zengarden" class="drawer-item">🪴 Дзен-Сад (Пассивный доход)</button>
|
||||||
<button id="btn-drawer-almanac" class="drawer-item">📖 Пригородный Альманах</button>
|
<button id="btn-drawer-almanac" class="drawer-item">📖 Пригородный Альманах</button>
|
||||||
<button id="btn-drawer-achievements" class="drawer-item">🏆 Зал Достижений и Трофеев</button>
|
<button id="btn-drawer-achievements" class="drawer-item">🏆 Зал Достижений и Трофеев</button>
|
||||||
<button id="btn-drawer-tree" class="drawer-item">🌳 Древо Мудрости и Чит-коды</button>
|
<button id="btn-drawer-tree" class="drawer-item">🌳 Древо Мудрости и Чит-коды</button>
|
||||||
<button id="btn-drawer-leaderboard" class="drawer-item">🏅 Доска Рекордов</button>
|
<button id="btn-drawer-leaderboard" class="drawer-item">🏅 Доска Рекордов</button>
|
||||||
|
<a href="configurator/" target="_blank" class="drawer-item" style="text-decoration: none;">⚙️ Конфигуратор & Админка</a>
|
||||||
<button id="btn-drawer-theme" class="drawer-item">🎨 Тема оформления: <span id="drawer-theme-label" style="color: #81d4fa; margin-left: auto;">Классика</span></button>
|
<button id="btn-drawer-theme" class="drawer-item">🎨 Тема оформления: <span id="drawer-theme-label" style="color: #81d4fa; margin-left: auto;">Классика</span></button>
|
||||||
<button id="btn-drawer-music" class="drawer-item">🎵 Фоновая музыка: <span id="drawer-music-label" style="color: #a5d6a7; margin-left: auto;">Вкл</span></button>
|
<button id="btn-drawer-music" class="drawer-item">🎵 Фоновая музыка: <span id="drawer-music-label" style="color: #a5d6a7; margin-left: auto;">Вкл</span></button>
|
||||||
<button id="btn-drawer-bugreport" class="drawer-item btn-drawer-bug">🐛 Баг-репорты и Чат</button>
|
<button id="btn-drawer-bugreport" class="drawer-item btn-drawer-bug">🐛 Баг-репорты и Чат</button>
|
||||||
@@ -130,6 +132,7 @@
|
|||||||
<!-- 4. Modern Activities Grid Hub -->
|
<!-- 4. Modern Activities Grid Hub -->
|
||||||
<div class="start-activities-grid">
|
<div class="start-activities-grid">
|
||||||
<button id="btn-shop" class="btn btn-shop btn-act">🏪 Магазин Дэйва</button>
|
<button id="btn-shop" class="btn btn-shop btn-act">🏪 Магазин Дэйва</button>
|
||||||
|
<button id="btn-casino-start" class="btn btn-casino btn-act" style="background: linear-gradient(135deg, #ad1457, #6a1b9a);">🎰 Казино & Банк</button>
|
||||||
<button id="btn-workshop-start" class="btn btn-workshop btn-act">🧪 Мастерская</button>
|
<button id="btn-workshop-start" class="btn btn-workshop btn-act">🧪 Мастерская</button>
|
||||||
<button id="btn-zengarden-start" class="btn btn-zengarden btn-act">🪴 Дзен-Сад</button>
|
<button id="btn-zengarden-start" class="btn btn-zengarden btn-act">🪴 Дзен-Сад</button>
|
||||||
<button id="btn-almanac-start" class="btn btn-almanac btn-act">📖 Альманах</button>
|
<button id="btn-almanac-start" class="btn btn-almanac btn-act">📖 Альманах</button>
|
||||||
@@ -189,6 +192,111 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Crazy Dave's Casino & Bank Modal -->
|
||||||
|
<div id="casino-modal" class="overlay hidden">
|
||||||
|
<div class="banner-card casino-card">
|
||||||
|
<div class="casino-header">
|
||||||
|
<h1 style="color: #ffca28; font-size: 26px; margin: 0; text-shadow: 0 0 10px rgba(255,202,40,0.5);">🎰 КАЗИНО И БАНК ДЭЙВА</h1>
|
||||||
|
<p style="font-size: 13px; color: #b0bec5; margin: 4px 0 10px 0;">
|
||||||
|
Испытай удачу в слот-машине или открой депозит в Банке Безумного Дэйва!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Casino Balance Bar -->
|
||||||
|
<div class="casino-balance-bar">
|
||||||
|
<div class="casino-stat-item">💰 Баланс: <strong id="casino-coins">0</strong> монет</div>
|
||||||
|
<div class="casino-stat-item">🏦 Вклад: <strong id="bank-deposit-stat">0</strong> (<span id="bank-interest-stat">+0%</span>)</div>
|
||||||
|
<div class="casino-stat-item" style="color: #ef5350;">📉 Долг: <strong id="bank-debt-stat">0</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Casino Tabs -->
|
||||||
|
<div class="casino-tabs">
|
||||||
|
<button id="tab-casino-slots" class="casino-tab active">🎰 Слот-Машина</button>
|
||||||
|
<button id="tab-casino-bank" class="casino-tab">🏦 Банк и Займы</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 1: Slot Machine -->
|
||||||
|
<div id="casino-content-slots" class="casino-content">
|
||||||
|
<!-- Slot Machine Visual Cabinet -->
|
||||||
|
<div class="slot-machine-cabinet">
|
||||||
|
<div class="slot-reels-frame">
|
||||||
|
<div class="slot-reel" id="reel-1"><span class="reel-symbol">🌻</span></div>
|
||||||
|
<div class="slot-reel" id="reel-2"><span class="reel-symbol">🟢</span></div>
|
||||||
|
<div class="slot-reel" id="reel-3"><span class="reel-symbol">🌰</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="slot-result-banner" class="slot-result-banner">Сделайте ставку и нажмите «КРУТИТЬ»!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls Row -->
|
||||||
|
<div class="slot-controls">
|
||||||
|
<div class="bet-selector">
|
||||||
|
<span style="font-size: 13px; color: #b0bec5;">Ставка:</span>
|
||||||
|
<button class="btn-bet active" data-bet="25">25</button>
|
||||||
|
<button class="btn-bet" data-bet="50">50</button>
|
||||||
|
<button class="btn-bet" data-bet="100">100</button>
|
||||||
|
<button class="btn-bet" data-bet="250">250</button>
|
||||||
|
</div>
|
||||||
|
<button id="btn-spin-slots" class="btn btn-spin">🎰 КРУТИТЬ!</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paytable Info -->
|
||||||
|
<div class="slot-paytable">
|
||||||
|
<div class="paytable-item">7️⃣7️⃣7️⃣ <strong>ДЖЕКПОТ x200</strong> (+5 💎)</div>
|
||||||
|
<div class="paytable-item">💎💎💎 <strong>x60</strong> (+2 💎)</div>
|
||||||
|
<div class="paytable-item">🍒🍒🍒 <strong>x25</strong></div>
|
||||||
|
<div class="paytable-item">🌰🌰🌰 <strong>x12</strong></div>
|
||||||
|
<div class="paytable-item">🟢🟢🟢 <strong>x8</strong></div>
|
||||||
|
<div class="paytable-item">🌻🌻🌻 <strong>x4</strong></div>
|
||||||
|
<div class="paytable-item">✨ Пара <strong>x1.5</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 2: Bank & Loans -->
|
||||||
|
<div id="casino-content-bank" class="casino-content hidden">
|
||||||
|
<div class="bank-grid">
|
||||||
|
<!-- Deposit Box -->
|
||||||
|
<div class="bank-box">
|
||||||
|
<h3 style="color: #69f0ae; margin: 0 0 6px 0;">📈 Депозитный Счёт</h3>
|
||||||
|
<p style="font-size: 12px; color: #b0bec5; margin: 0 0 10px 0;">
|
||||||
|
Ставка: <strong>5% в час</strong> (офлайн-начисление до 48 часов). Приумножайте сбережения!
|
||||||
|
</p>
|
||||||
|
<div style="font-size: 14px; margin-bottom: 8px;">
|
||||||
|
Текущий вклад: <strong id="bank-deposit-val" style="color: #ffd54f;">0</strong> 💰
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 13px; margin-bottom: 12px; color: #81c784;">
|
||||||
|
Накопленные проценты: +<strong id="bank-interest-val">0</strong> 💰
|
||||||
|
</div>
|
||||||
|
<div class="bank-actions-row">
|
||||||
|
<input type="number" id="input-deposit-amount" placeholder="Сумма..." min="10" step="50" style="width: 100px; padding: 6px; border-radius: 6px; border: 1px solid #455a64; background: #263238; color: #fff;">
|
||||||
|
<button id="btn-bank-deposit" class="btn btn-small" style="background: #2e7d32;">Внести</button>
|
||||||
|
<button id="btn-bank-withdraw" class="btn btn-small" style="background: #1565c0;">Снять всё</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loan Box -->
|
||||||
|
<div class="bank-box">
|
||||||
|
<h3 style="color: #ff8a65; margin: 0 0 6px 0;">💳 Микрокредит Дэйва</h3>
|
||||||
|
<p style="font-size: 12px; color: #b0bec5; margin: 0 0 10px 0;">
|
||||||
|
Срочно нужны монеты на прокачку? Возьмите займ до <strong>2500 монет</strong> (комиссия 10%).
|
||||||
|
</p>
|
||||||
|
<div style="font-size: 14px; margin-bottom: 8px;">
|
||||||
|
Текущий долг: <strong id="bank-debt-val" style="color: #ef5350;">0</strong> 💰
|
||||||
|
</div>
|
||||||
|
<div class="bank-actions-row">
|
||||||
|
<input type="number" id="input-loan-amount" placeholder="Сумма..." min="100" max="2500" step="100" style="width: 100px; padding: 6px; border-radius: 6px; border: 1px solid #455a64; background: #263238; color: #fff;">
|
||||||
|
<button id="btn-bank-take-loan" class="btn btn-small" style="background: #e65100;">Взять займ</button>
|
||||||
|
<button id="btn-bank-repay-loan" class="btn btn-small" style="background: #455a64;">Погасить долг</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 14px; text-align: center;">
|
||||||
|
<button id="btn-close-casino" class="btn" style="padding: 8px 32px;">НАЗАД</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pre-Battle Seed Selection & Inventory Modal -->
|
<!-- Pre-Battle Seed Selection & Inventory Modal -->
|
||||||
<div id="seed-select-modal" class="overlay hidden">
|
<div id="seed-select-modal" class="overlay hidden">
|
||||||
<div class="banner-card seed-select-card">
|
<div class="banner-card seed-select-card">
|
||||||
@@ -266,6 +374,7 @@
|
|||||||
<div class="workshop-tabs">
|
<div class="workshop-tabs">
|
||||||
<button id="tab-plants" class="ws-tab active">🌿 Прокачка Растений</button>
|
<button id="tab-plants" class="ws-tab active">🌿 Прокачка Растений</button>
|
||||||
<button id="tab-perks" class="ws-tab">✨ Перки Садовода</button>
|
<button id="tab-perks" class="ws-tab">✨ Перки Садовода</button>
|
||||||
|
<button id="tab-zombies" class="ws-tab">🧟 Мутации Зомби</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,6 +388,14 @@
|
|||||||
<div id="perks-upgrade-list" class="upgrade-grid"></div>
|
<div id="perks-upgrade-list" class="upgrade-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 3: Zombie Mutations -->
|
||||||
|
<div id="ws-content-zombies" class="ws-content hidden">
|
||||||
|
<div style="font-size: 13px; color: #b0bec5; margin-bottom: 12px; text-align: center;">
|
||||||
|
Прокачивайте зомби для режима «Я, Зомби»! Усиленная броня, скорость, вампиризм и таран!
|
||||||
|
</div>
|
||||||
|
<div id="zombies-upgrade-list" class="upgrade-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 12px; text-align: center;">
|
<div style="margin-top: 12px; text-align: center;">
|
||||||
<button id="btn-close-workshop" class="btn" style="padding: 8px 32px;">НАЗАД</button>
|
<button id="btn-close-workshop" class="btn" style="padding: 8px 32px;">НАЗАД</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -502,6 +619,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Sessions and Devices Section -->
|
||||||
|
<div class="auth-sessions-section" style="margin-top: 14px; padding: 12px; background: rgba(0, 0, 0, 0.3); border-radius: 10px; border: 1px solid rgba(129, 199, 132, 0.3);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
|
<div style="font-size: 13px; font-weight: bold; color: #a5d6a7; display: flex; align-items: center; gap: 6px;">
|
||||||
|
📱 МОИ УСТРОЙСТВА И АКТИВНЫЕ СЕССИИ
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-refresh-sessions" class="btn btn-secondary" style="font-size: 11px; padding: 3px 8px;" title="Обновить список устройств">🔄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="auth-sessions-list" class="auth-sessions-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 180px; overflow-y: auto; padding-right: 2px;">
|
||||||
|
<div style="font-size: 12px; color: #b0bec5; text-align: center; padding: 10px;">Загрузка активных сессий...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 10px; text-align: center;">
|
||||||
|
<button type="button" id="btn-terminate-other-sessions" class="btn btn-secondary" style="font-size: 11px; padding: 6px 12px; width: 100%; color: #ffab91; border-color: rgba(255, 87, 34, 0.4);">
|
||||||
|
🔒 Завершить сессии на всех других устройствах
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 14px;">
|
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 14px;">
|
||||||
<button type="button" id="btn-auth-logout" class="btn btn-danger">🚪 Выйти из аккаунта</button>
|
<button type="button" id="btn-auth-logout" class="btn btn-danger">🚪 Выйти из аккаунта</button>
|
||||||
<button type="button" id="btn-auth-close-profile" class="btn btn-secondary">Закрыть</button>
|
<button type="button" id="btn-auth-close-profile" class="btn btn-secondary">Закрыть</button>
|
||||||
@@ -673,6 +810,7 @@
|
|||||||
|
|
||||||
<!-- Scripts loaded in dependency order -->
|
<!-- Scripts loaded in dependency order -->
|
||||||
<script src="js/utils/assets.js"></script>
|
<script src="js/utils/assets.js"></script>
|
||||||
|
<script src="js/systems/config.js"></script>
|
||||||
<script src="js/systems/audio.js"></script>
|
<script src="js/systems/audio.js"></script>
|
||||||
<script src="js/utils/test_api.js"></script>
|
<script src="js/utils/test_api.js"></script>
|
||||||
<script src="js/systems/auth.js"></script>
|
<script src="js/systems/auth.js"></script>
|
||||||
@@ -683,6 +821,8 @@
|
|||||||
<script src="js/systems/economy.js"></script>
|
<script src="js/systems/economy.js"></script>
|
||||||
<script src="js/systems/combat.js"></script>
|
<script src="js/systems/combat.js"></script>
|
||||||
<script src="js/systems/workshop.js"></script>
|
<script src="js/systems/workshop.js"></script>
|
||||||
|
<script src="js/systems/zombielab.js"></script>
|
||||||
|
<script src="js/systems/casino.js"></script>
|
||||||
<script src="js/systems/zengarden.js"></script>
|
<script src="js/systems/zengarden.js"></script>
|
||||||
<script src="js/systems/almanac.js"></script>
|
<script src="js/systems/almanac.js"></script>
|
||||||
<script src="js/systems/achievements.js"></script>
|
<script src="js/systems/achievements.js"></script>
|
||||||
|
|||||||
+179
-4
@@ -21,6 +21,13 @@ window.Plant = class Plant {
|
|||||||
this.recoilDps = 0;
|
this.recoilDps = 0;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'mega_sunflower':
|
||||||
|
this.maxHealth = 5000;
|
||||||
|
this.sunInterval = 5;
|
||||||
|
this.sunAmount = 100;
|
||||||
|
this.sunTimer = 2;
|
||||||
|
this.isRaidBoss = true;
|
||||||
|
break;
|
||||||
case 'sunflower':
|
case 'sunflower':
|
||||||
this.sunInterval = this.level === 3 ? 9 : (this.level === 2 ? 10 : 13);
|
this.sunInterval = this.level === 3 ? 9 : (this.level === 2 ? 10 : 13);
|
||||||
this.sunAmount = this.level >= 2 ? 35 : 25;
|
this.sunAmount = this.level >= 2 ? 35 : 25;
|
||||||
@@ -108,6 +115,17 @@ window.Plant = class Plant {
|
|||||||
this.shootTimer = 1.0;
|
this.shootTimer = 1.0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.PvZConfig) {
|
||||||
|
const hpMult = window.PvZConfig.getPlantHpMult();
|
||||||
|
const dmgMult = window.PvZConfig.getPlantDamageMult();
|
||||||
|
const sunIntMult = window.PvZConfig.getSunIntervalMult();
|
||||||
|
|
||||||
|
this.maxHealth = Math.round(this.maxHealth * hpMult);
|
||||||
|
this.damage = Math.round(this.damage * dmgMult);
|
||||||
|
if (this.sunInterval) this.sunInterval = this.sunInterval * sunIntMult;
|
||||||
|
}
|
||||||
|
|
||||||
this.health = this.maxHealth;
|
this.health = this.maxHealth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,15 +137,25 @@ window.Plant = class Plant {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Sunflower & Sun-shroom
|
// 1. Sunflower, Mega Sunflower & Sun-shroom
|
||||||
if (this.type === 'sunflower') {
|
if (this.type === 'sunflower' || this.type === 'mega_sunflower') {
|
||||||
this.sunTimer += dt;
|
this.sunTimer += dt;
|
||||||
if (this.sunTimer >= this.sunInterval) {
|
if (this.sunTimer >= this.sunInterval) {
|
||||||
this.sunTimer = 0;
|
this.sunTimer = 0;
|
||||||
gameState.spawnSun(this.x + (Math.random() * 20 - 10), this.y + (Math.random() * 20 - 10), this.sunAmount || 25, false);
|
gameState.spawnSun(this.x + (Math.random() * 20 - 10), this.y + (Math.random() * 20 - 10), this.sunAmount || 25, false);
|
||||||
|
|
||||||
// Tier 3: Heal neighboring plants!
|
if (this.type === 'mega_sunflower') {
|
||||||
if (this.level === 3) {
|
// Raid Boss solar pulse: heal all plants on the lawn
|
||||||
|
for (const p of gameState.plants) {
|
||||||
|
if (p !== this && p.health > 0) {
|
||||||
|
p.health = Math.min(p.maxHealth, p.health + 25);
|
||||||
|
if (gameState.combat && gameState.combat.createSplash) {
|
||||||
|
gameState.combat.createSplash(p.x, p.y, '#ffd54f', 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.level === 3) {
|
||||||
|
// Tier 3: Heal neighboring plants!
|
||||||
for (const p of gameState.plants) {
|
for (const p of gameState.plants) {
|
||||||
if (p !== this && Math.abs(p.col - this.col) <= 1 && Math.abs(p.row - this.row) <= 1 && p.health > 0) {
|
if (p !== this && Math.abs(p.col - this.col) <= 1 && Math.abs(p.row - this.row) <= 1 && p.health > 0) {
|
||||||
p.health = Math.min(p.maxHealth, p.health + 30);
|
p.health = Math.min(p.maxHealth, p.health + 30);
|
||||||
@@ -426,6 +454,9 @@ window.Plant = class Plant {
|
|||||||
case 'melonpult':
|
case 'melonpult':
|
||||||
this.drawMelonpult(ctx);
|
this.drawMelonpult(ctx);
|
||||||
break;
|
break;
|
||||||
|
case 'mega_sunflower':
|
||||||
|
this.drawMegaSunflower(ctx);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier Stars (Level 2 & 3)
|
// Tier Stars (Level 2 & 3)
|
||||||
@@ -494,6 +525,150 @@ window.Plant = class Plant {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawMegaSunflower(ctx) {
|
||||||
|
// Grand glowing aura
|
||||||
|
const glowGradient = ctx.createRadialGradient(0, -10, 10, 0, -10, 60);
|
||||||
|
glowGradient.addColorStop(0, 'rgba(255, 215, 0, 0.6)');
|
||||||
|
glowGradient.addColorStop(0.7, 'rgba(255, 152, 0, 0.3)');
|
||||||
|
glowGradient.addColorStop(1, 'rgba(255, 100, 0, 0)');
|
||||||
|
ctx.fillStyle = glowGradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, -10, 60, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Leaves / base
|
||||||
|
ctx.fillStyle = '#2e7d32';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-26, 22, 22, 10, -0.4, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(26, 22, 22, 10, 0.4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Big stem
|
||||||
|
ctx.strokeStyle = '#1b5e20';
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 32);
|
||||||
|
ctx.quadraticCurveTo(-5, 12, 0, -5);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 16 Petals with pulse and slow rotation
|
||||||
|
const petalCount = 16;
|
||||||
|
const pulse = 1 + Math.sin(this.animTime * 3) * 0.08;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(0, -10);
|
||||||
|
ctx.scale(pulse, pulse);
|
||||||
|
|
||||||
|
for (let i = 0; i < petalCount; i++) {
|
||||||
|
const angle = (i * (Math.PI * 2) / petalCount) + this.animTime * 0.3;
|
||||||
|
ctx.save();
|
||||||
|
ctx.rotate(angle);
|
||||||
|
|
||||||
|
// Outer petal
|
||||||
|
ctx.fillStyle = i % 2 === 0 ? '#ffca28' : '#ffa000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, -32, 11, 20, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Inner petal highlight
|
||||||
|
ctx.fillStyle = '#fff59d';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, -30, 5, 12, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flower Center
|
||||||
|
ctx.fillStyle = '#5d4037';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 24, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#8d6e63';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Smiling Face & Eyes
|
||||||
|
ctx.fillStyle = '#212121';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-8, -4, 4, 0, Math.PI * 2);
|
||||||
|
ctx.arc(8, -4, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eye sparkles
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-9, -6, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.arc(7, -6, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Big Royal Smile
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 5, 9, 0.2, Math.PI - 0.2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Royal Crown
|
||||||
|
ctx.fillStyle = '#ffd700';
|
||||||
|
ctx.strokeStyle = '#e65100';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-16, -22);
|
||||||
|
ctx.lineTo(-14, -36);
|
||||||
|
ctx.lineTo(-6, -26);
|
||||||
|
ctx.lineTo(0, -40);
|
||||||
|
ctx.lineTo(6, -26);
|
||||||
|
ctx.lineTo(14, -36);
|
||||||
|
ctx.lineTo(16, -22);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Crown rubies
|
||||||
|
ctx.fillStyle = '#e91e63';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, -32, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.arc(-10, -28, 2, 0, Math.PI * 2);
|
||||||
|
ctx.arc(10, -28, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Raid Boss Overhead Health Bar & Label
|
||||||
|
const barWidth = 68;
|
||||||
|
const barHeight = 8;
|
||||||
|
const barX = -barWidth / 2;
|
||||||
|
const barY = -54;
|
||||||
|
const hpRatio = Math.max(0, this.health / this.maxHealth);
|
||||||
|
|
||||||
|
// Dark background
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
|
||||||
|
ctx.fillRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2);
|
||||||
|
|
||||||
|
// Health bar with gradient
|
||||||
|
const hpGrad = ctx.createLinearGradient(barX, barY, barX + barWidth, barY);
|
||||||
|
hpGrad.addColorStop(0, '#ffd700');
|
||||||
|
hpGrad.addColorStop(1, '#ff3d00');
|
||||||
|
ctx.fillStyle = hpGrad;
|
||||||
|
ctx.fillRect(barX, barY, barWidth * hpRatio, barHeight);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2);
|
||||||
|
|
||||||
|
// Boss Title
|
||||||
|
ctx.font = 'bold 9px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillStyle = '#ffe082';
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
ctx.shadowBlur = 3;
|
||||||
|
ctx.fillText(`👑 MEGA SUNFLOWER [${Math.ceil(this.health)}]`, 0, barY - 4);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
drawPeashooter(ctx, isSnow) {
|
drawPeashooter(ctx, isSnow) {
|
||||||
const mainColor = isSnow ? '#29b6f6' : '#4caf50';
|
const mainColor = isSnow ? '#29b6f6' : '#4caf50';
|
||||||
const darkColor = isSnow ? '#0288d1' : '#2e7d32';
|
const darkColor = isSnow ? '#0288d1' : '#2e7d32';
|
||||||
|
|||||||
+54
-10
@@ -58,15 +58,47 @@ window.Zombie = class Zombie {
|
|||||||
this.baseSpeed = 24;
|
this.baseSpeed = 24;
|
||||||
break;
|
break;
|
||||||
case 'flag':
|
case 'flag':
|
||||||
this.maxHealth = 110;
|
this.maxHealth = 140;
|
||||||
this.baseSpeed = 26;
|
this.baseSpeed = 26;
|
||||||
break;
|
break;
|
||||||
case 'normal':
|
|
||||||
default:
|
default:
|
||||||
this.maxHealth = 100;
|
this.maxHealth = 100;
|
||||||
this.helmetHealth = 0;
|
this.helmetHealth = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply Zombie Lab Mutations (if in I, Zombie mode or if mutation level passed)
|
||||||
|
this.mutationLevel = 1;
|
||||||
|
if (window._pvzEngine && window._pvzEngine.zombieLab && window._pvzEngine.currentMode === 'izombie') {
|
||||||
|
this.mutationLevel = window._pvzEngine.zombieLab.getZombieLevel(type) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mutationLevel >= 2) {
|
||||||
|
if (type === 'normal') this.baseSpeed *= 1.35;
|
||||||
|
if (type === 'conehead') this.helmetHealth += 150;
|
||||||
|
if (type === 'polevaulter') this.maxVaults = 2;
|
||||||
|
if (type === 'buckethead') this.helmetHealth += 250;
|
||||||
|
if (type === 'football') this.helmetHealth += 250;
|
||||||
|
}
|
||||||
|
if (this.mutationLevel >= 3) {
|
||||||
|
if (type === 'normal') this.isVampire = true;
|
||||||
|
if (type === 'conehead') { this.helmetHealth += 150; this.reflectDmg = 0.25; }
|
||||||
|
if (type === 'polevaulter') { this.maxVaults = 2; this.turboSprint = true; }
|
||||||
|
if (type === 'buckethead') { this.helmetHealth += 300; this.iceImmune = true; }
|
||||||
|
if (type === 'football') { this.helmetHealth += 300; this.mineImmune = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.PvZConfig) {
|
||||||
|
const hpMult = window.PvZConfig.getZombieHpMult();
|
||||||
|
const speedMult = window.PvZConfig.getZombieSpeedMult();
|
||||||
|
const dmgMult = window.PvZConfig.getZombieDamageMult();
|
||||||
|
|
||||||
|
this.maxHealth = Math.round(this.maxHealth * hpMult);
|
||||||
|
if (this.helmetHealth) this.helmetHealth = Math.round(this.helmetHealth * hpMult);
|
||||||
|
this.baseSpeed = this.baseSpeed * speedMult;
|
||||||
|
this.eatingDps = this.eatingDps * dmgMult;
|
||||||
|
}
|
||||||
|
|
||||||
this.speed = this.baseSpeed;
|
this.speed = this.baseSpeed;
|
||||||
this.health = this.maxHealth;
|
this.health = this.maxHealth;
|
||||||
}
|
}
|
||||||
@@ -128,8 +160,13 @@ window.Zombie = class Zombie {
|
|||||||
this.x -= 150 * dt;
|
this.x -= 150 * dt;
|
||||||
if (this.vaultTimer >= 0.6) {
|
if (this.vaultTimer >= 0.6) {
|
||||||
this.isVaulting = false;
|
this.isVaulting = false;
|
||||||
this.baseSpeed = 22; // Normal speed after jumping
|
if (this.maxVaults && this.maxVaults > 1) {
|
||||||
this.speed = 22;
|
this.maxVaults--;
|
||||||
|
this.hasPole = true; // Can vault second time!
|
||||||
|
}
|
||||||
|
const postSpeed = this.turboSprint ? 35 : 22;
|
||||||
|
this.baseSpeed = postSpeed;
|
||||||
|
this.speed = postSpeed;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,6 +193,9 @@ window.Zombie = class Zombie {
|
|||||||
if (this.eatTimer >= 0.35) {
|
if (this.eatTimer >= 0.35) {
|
||||||
this.eatTimer = 0;
|
this.eatTimer = 0;
|
||||||
this.targetPlant.takeDamage(this.eatingDps * 0.35);
|
this.targetPlant.takeDamage(this.eatingDps * 0.35);
|
||||||
|
if (this.isVampire) {
|
||||||
|
this.health = Math.min(this.maxHealth, this.health + 45);
|
||||||
|
}
|
||||||
if (window.PvZAudio) window.PvZAudio.playChomp();
|
if (window.PvZAudio) window.PvZAudio.playChomp();
|
||||||
if (this.targetPlant && this.targetPlant.type === 'wallnut' && gameState && gameState.achievements) {
|
if (this.targetPlant && this.targetPlant.type === 'wallnut' && gameState && gameState.achievements) {
|
||||||
gameState.achievements.trigger('nut_wall');
|
gameState.achievements.trigger('nut_wall');
|
||||||
@@ -176,11 +216,14 @@ window.Zombie = class Zombie {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
takeDamage(amount, slows = false) {
|
takeDamage(amount, slows = false, source = null) {
|
||||||
this.hitFlashTimer = 0.08;
|
this.hitFlashTimer = 0.08;
|
||||||
if (slows) {
|
if (slows && !this.iceImmune) {
|
||||||
this.slowTimer = 4.0;
|
this.slowTimer = 4.0;
|
||||||
}
|
}
|
||||||
|
if (this.reflectDmg && source && source.takeDamage) {
|
||||||
|
source.takeDamage(amount * this.reflectDmg);
|
||||||
|
}
|
||||||
|
|
||||||
// Newspaper mechanics
|
// Newspaper mechanics
|
||||||
if (this.type === 'newspaper' && this.hasPaper) {
|
if (this.type === 'newspaper' && this.hasPaper) {
|
||||||
@@ -511,8 +554,6 @@ window.Zombie = class Zombie {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Frost Crystals visual effect around slowed / frozen zombie
|
// Frost Crystals visual effect around slowed / frozen zombie
|
||||||
if (isSlow && !isHit) {
|
if (isSlow && !isHit) {
|
||||||
this.drawFrostCrystals(ctx);
|
this.drawFrostCrystals(ctx);
|
||||||
@@ -530,13 +571,16 @@ window.Zombie = class Zombie {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health bar if damaged
|
// Health bar if damaged (drawn directly above zombie head)
|
||||||
if (this.health < this.maxHealth) {
|
if (this.health < this.maxHealth) {
|
||||||
const pct = Math.max(0, this.health / this.maxHealth);
|
const pct = Math.max(0, this.health / this.maxHealth);
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)';
|
||||||
ctx.fillRect(-20, -56, 40, 5);
|
ctx.fillRect(-20, -56, 40, 5);
|
||||||
ctx.fillStyle = pct > 0.4 ? '#8bc34a' : '#f44336';
|
ctx.fillStyle = pct > 0.4 ? '#8bc34a' : '#f44336';
|
||||||
ctx.fillRect(-20, -56, 40 * pct, 5);
|
ctx.fillRect(-20, -56, 40 * pct, 5);
|
||||||
|
ctx.strokeStyle = '#222';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.strokeRect(-20, -56, 40, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
+714
-82
@@ -47,6 +47,12 @@ window.GameEngine = class GameEngine {
|
|||||||
this.feedback = window.PvZFeedback;
|
this.feedback = window.PvZFeedback;
|
||||||
this.feedback.init();
|
this.feedback.init();
|
||||||
}
|
}
|
||||||
|
if (window.CasinoSystem) {
|
||||||
|
this.casino = new window.CasinoSystem(this.economy);
|
||||||
|
}
|
||||||
|
if (window.ZombieLabSystem) {
|
||||||
|
this.zombieLab = new window.ZombieLabSystem(this.economy, this.workshop);
|
||||||
|
}
|
||||||
this.wave = new window.WaveSystem();
|
this.wave = new window.WaveSystem();
|
||||||
this.combat = new window.CombatSystem();
|
this.combat = new window.CombatSystem();
|
||||||
if (window.ProjectilePool) {
|
if (window.ProjectilePool) {
|
||||||
@@ -97,6 +103,14 @@ window.GameEngine = class GameEngine {
|
|||||||
}
|
}
|
||||||
window._pvzEngine = this; // allow auth.js to trigger profile reload
|
window._pvzEngine = this; // allow auth.js to trigger profile reload
|
||||||
|
|
||||||
|
// Dynamic profile sync event listener
|
||||||
|
window.addEventListener('pvz:profile-updated', () => {
|
||||||
|
this.loadUserData();
|
||||||
|
this.updateProfileUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
this.updateShopUI();
|
||||||
|
});
|
||||||
|
|
||||||
// Bind events
|
// Bind events
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.bindUI();
|
this.bindUI();
|
||||||
@@ -131,6 +145,8 @@ window.GameEngine = class GameEngine {
|
|||||||
if (this.achievements) this.achievements.loadFromAuth();
|
if (this.achievements) this.achievements.loadFromAuth();
|
||||||
if (this.tree) this.tree.loadFromAuth();
|
if (this.tree) this.tree.loadFromAuth();
|
||||||
if (this.leaderboards) this.leaderboards.loadFromAuth();
|
if (this.leaderboards) this.leaderboards.loadFromAuth();
|
||||||
|
if (this.casino) this.casino.loadFromAuth();
|
||||||
|
if (this.zombieLab) this.zombieLab.loadFromAuth();
|
||||||
} else {
|
} else {
|
||||||
this.currentLevel = parseInt(localStorage.getItem('pvz_level') || '1', 10);
|
this.currentLevel = parseInt(localStorage.getItem('pvz_level') || '1', 10);
|
||||||
this.maxUnlockedLevel = parseInt(localStorage.getItem('pvz_max_level') || this.currentLevel.toString(), 10);
|
this.maxUnlockedLevel = parseInt(localStorage.getItem('pvz_max_level') || this.currentLevel.toString(), 10);
|
||||||
@@ -162,9 +178,12 @@ window.GameEngine = class GameEngine {
|
|||||||
|
|
||||||
saveUserData() {
|
saveUserData() {
|
||||||
if (window.PvZAuth) {
|
if (window.PvZAuth) {
|
||||||
|
if (this.workshop) this.workshop.saveToAuth();
|
||||||
if (this.achievements) this.achievements.saveToAuth();
|
if (this.achievements) this.achievements.saveToAuth();
|
||||||
if (this.tree) this.tree.saveToAuth();
|
if (this.tree) this.tree.saveToAuth();
|
||||||
if (this.leaderboards) this.leaderboards.saveToAuth();
|
if (this.leaderboards) this.leaderboards.saveToAuth();
|
||||||
|
if (this.casino) this.casino.saveToAuth();
|
||||||
|
if (this.zombieLab) this.zombieLab.saveToAuth();
|
||||||
window.PvZAuth.saveUserData({
|
window.PvZAuth.saveUserData({
|
||||||
coins: this.economy ? this.economy.totalCoins : undefined,
|
coins: this.economy ? this.economy.totalCoins : undefined,
|
||||||
level: this.currentLevel,
|
level: this.currentLevel,
|
||||||
@@ -319,11 +338,107 @@ window.GameEngine = class GameEngine {
|
|||||||
if (statPlants) statPlants.textContent = (stats.plantsPlanted || 0).toLocaleString('ru-RU');
|
if (statPlants) statPlants.textContent = (stats.plantsPlanted || 0).toLocaleString('ru-RU');
|
||||||
if (statLevelsWon) statLevelsWon.textContent = (stats.levelsCompleted || 0).toLocaleString('ru-RU');
|
if (statLevelsWon) statLevelsWon.textContent = (stats.levelsCompleted || 0).toLocaleString('ru-RU');
|
||||||
if (statCoinsEarned) statCoinsEarned.textContent = (stats.coinsEarned || 0).toLocaleString('ru-RU');
|
if (statCoinsEarned) statCoinsEarned.textContent = (stats.coinsEarned || 0).toLocaleString('ru-RU');
|
||||||
|
|
||||||
|
this.renderUserSessionsUI();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renderUserSessionsUI() {
|
||||||
|
const listEl = document.getElementById('auth-sessions-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
if (!window.PvZAuth || window.PvZAuth.isGuest()) {
|
||||||
|
listEl.innerHTML = '<div style="font-size: 12px; color: #b0bec5; text-align: center; padding: 8px;">Войдите в аккаунт для просмотра сессий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await window.PvZAuth.fetchSessions();
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
listEl.innerHTML = '<div style="font-size: 12px; color: #b0bec5; text-align: center; padding: 8px;">Нет активных сессий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
sessions.forEach(s => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `session-card ${s.isCurrent ? 'is-current' : ''}`;
|
||||||
|
|
||||||
|
// Choose icon based on device name
|
||||||
|
let icon = '🖥️';
|
||||||
|
const dName = (s.deviceName || '').toLowerCase();
|
||||||
|
if (dName.includes('iphone') || dName.includes('android') || dName.includes('phone') || dName.includes('mobile')) {
|
||||||
|
icon = '📱';
|
||||||
|
} else if (dName.includes('ipad') || dName.includes('tablet')) {
|
||||||
|
icon = '📟';
|
||||||
|
} else if (dName.includes('mac') || dName.includes('apple')) {
|
||||||
|
icon = '🍎';
|
||||||
|
} else if (dName.includes('windows') || dName.includes('linux') || dName.includes('chrome') || dName.includes('pc')) {
|
||||||
|
icon = '💻';
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconDiv = document.createElement('div');
|
||||||
|
iconDiv.className = 'session-icon';
|
||||||
|
iconDiv.textContent = icon;
|
||||||
|
|
||||||
|
const detailsDiv = document.createElement('div');
|
||||||
|
detailsDiv.className = 'session-details';
|
||||||
|
|
||||||
|
const devNameDiv = document.createElement('div');
|
||||||
|
devNameDiv.className = 'session-dev-name';
|
||||||
|
devNameDiv.textContent = s.deviceName || 'Браузер / Компьютер';
|
||||||
|
|
||||||
|
const metaDiv = document.createElement('div');
|
||||||
|
metaDiv.className = 'session-meta';
|
||||||
|
|
||||||
|
const ipSpan = document.createElement('span');
|
||||||
|
ipSpan.textContent = `IP: ${s.ipAddress || '127.0.0.1'}`;
|
||||||
|
|
||||||
|
const activeSpan = document.createElement('span');
|
||||||
|
activeSpan.textContent = `Активность: ${s.lastActive || s.createdAt || 'Только что'}`;
|
||||||
|
|
||||||
|
metaDiv.appendChild(ipSpan);
|
||||||
|
metaDiv.appendChild(activeSpan);
|
||||||
|
|
||||||
|
detailsDiv.appendChild(devNameDiv);
|
||||||
|
detailsDiv.appendChild(metaDiv);
|
||||||
|
|
||||||
|
card.appendChild(iconDiv);
|
||||||
|
card.appendChild(detailsDiv);
|
||||||
|
|
||||||
|
if (s.isCurrent) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'session-current-badge';
|
||||||
|
badge.textContent = '🟢 Текущее устройство';
|
||||||
|
card.appendChild(badge);
|
||||||
|
} else {
|
||||||
|
const btnTerm = document.createElement('button');
|
||||||
|
btnTerm.type = 'button';
|
||||||
|
btnTerm.className = 'btn btn-danger btn-terminate-session';
|
||||||
|
btnTerm.textContent = 'Завершить';
|
||||||
|
btnTerm.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
btnTerm.disabled = true;
|
||||||
|
btnTerm.textContent = '⏳';
|
||||||
|
const res = await window.PvZAuth.terminateSession(s.token);
|
||||||
|
if (res && res.success) {
|
||||||
|
if (window.PvZAuth && window.PvZAuth.showNotification) {
|
||||||
|
window.PvZAuth.showNotification('Сессия устройства завершена', s.deviceName, '🔒');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.PvZAuth && window.PvZAuth.showNotification) {
|
||||||
|
window.PvZAuth.showNotification('Ошибка завершения сессии', res?.message || 'Сбой', '❌');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.renderUserSessionsUI();
|
||||||
|
});
|
||||||
|
card.appendChild(btnTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
openMainMenu() {
|
openMainMenu() {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
if (window.PvZAudio) window.PvZAudio.stopMusic();
|
if (window.PvZAudio) window.PvZAudio.stopMusic();
|
||||||
@@ -333,7 +448,7 @@ window.GameEngine = class GameEngine {
|
|||||||
// Close any game-phase modals that might have been left open
|
// Close any game-phase modals that might have been left open
|
||||||
['game-over-modal', 'victory-modal', 'draft-modal', 'zengarden-modal',
|
['game-over-modal', 'victory-modal', 'draft-modal', 'zengarden-modal',
|
||||||
'workshop-modal', 'leaderboard-modal', 'achievement-modal', 'almanac-modal',
|
'workshop-modal', 'leaderboard-modal', 'achievement-modal', 'almanac-modal',
|
||||||
'bugreport-modal', 'campaign-modal', 'tree-modal'].forEach(id => {
|
'bugreport-modal', 'campaign-modal', 'tree-modal', 'casino-modal'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.classList.add('hidden');
|
if (el) el.classList.add('hidden');
|
||||||
});
|
});
|
||||||
@@ -417,75 +532,133 @@ window.GameEngine = class GameEngine {
|
|||||||
this.rakeCol = 7;
|
this.rakeCol = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.wave && this.wave.startingSun !== null && this.wave.startingSun !== undefined && this.wave.startingSun >= 0) {
|
||||||
|
this.economy.sun = this.wave.startingSun;
|
||||||
|
}
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.lastTime = performance.now();
|
this.lastTime = performance.now();
|
||||||
const musicTheme = (mode === 'boss' || mode === 'bossrush') ? 'boss' : this.wave.biome;
|
const musicTheme = (mode === 'boss' || mode === 'bossrush') ? 'boss' : this.wave.biome;
|
||||||
if (window.PvZAudio) window.PvZAudio.startMusic(musicTheme);
|
if (window.PvZAudio) window.PvZAudio.startMusic(musicTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupIZombieLawn() {
|
setupIZombieLawn(stage) {
|
||||||
this.plants = [];
|
this.plants = [];
|
||||||
this.izombieSetupDone = true;
|
this.izombieSetupDone = true;
|
||||||
|
const currentStage = stage || (this.zombieLab ? (this.zombieLab.selectedStage || this.zombieLab.stage || 1) : 1);
|
||||||
|
|
||||||
// Col 0: Sunflowers in rows 0..4 (5 trophy targets)
|
const addPlant = (type, col, row, options = {}) => {
|
||||||
for (let r = 0; r < 5; r++) {
|
const b = this.grid.getCellBounds(col, row);
|
||||||
const b0 = this.grid.getCellBounds(0, r);
|
const p = new window.Plant(type, col, row, b.x, b.y, b.w, b.h);
|
||||||
const sf = new window.Plant('sunflower', 0, r, b0.x, b0.y, b0.w, b0.h);
|
if (options.isArmed) {
|
||||||
this.grid.setPlant(0, r, sf);
|
p.isArmed = true;
|
||||||
this.plants.push(sf);
|
p.armTimer = 15;
|
||||||
|
}
|
||||||
|
this.grid.setPlant(col, row, p);
|
||||||
|
this.plants.push(p);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentStage === 5) {
|
||||||
|
// Stage 5: RAID BOSS - Mega Sunflower (5000 HP) with Royal Defense!
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
if (r === 2) {
|
||||||
|
addPlant('mega_sunflower', 0, r); // Center lane Raid Boss!
|
||||||
|
} else {
|
||||||
|
addPlant('sunflower', 0, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addPlant('snowpea', 2, 0);
|
||||||
|
addPlant('tallnut', 4, 0);
|
||||||
|
|
||||||
|
addPlant('repeater', 1, 1);
|
||||||
|
addPlant('repeater', 3, 1);
|
||||||
|
addPlant('tallnut', 4, 1);
|
||||||
|
|
||||||
|
// Lane 2: The Boss Lane
|
||||||
|
addPlant('repeater', 1, 2);
|
||||||
|
addPlant('snowpea', 2, 2);
|
||||||
|
addPlant('tallnut', 4, 2);
|
||||||
|
|
||||||
|
addPlant('repeater', 1, 3);
|
||||||
|
addPlant('repeater', 3, 3);
|
||||||
|
addPlant('tallnut', 4, 3);
|
||||||
|
|
||||||
|
addPlant('snowpea', 2, 4);
|
||||||
|
addPlant('tallnut', 4, 4);
|
||||||
|
|
||||||
|
} else if (currentStage === 4) {
|
||||||
|
// Stage 4: The Great Wall (Tallnuts & repeaters)
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
addPlant('sunflower', 0, r);
|
||||||
|
addPlant('repeater', 1, r);
|
||||||
|
addPlant('tallnut', 3, r);
|
||||||
|
}
|
||||||
|
addPlant('potatomine', 4, 1, { isArmed: true });
|
||||||
|
addPlant('potatomine', 4, 3, { isArmed: true });
|
||||||
|
|
||||||
|
} else if (currentStage === 3) {
|
||||||
|
// Stage 3: Frost & Thorns (Snowpeas & armed mines)
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
addPlant('sunflower', 0, r);
|
||||||
|
}
|
||||||
|
addPlant('snowpea', 2, 0);
|
||||||
|
addPlant('wallnut', 4, 0);
|
||||||
|
|
||||||
|
addPlant('repeater', 1, 1);
|
||||||
|
addPlant('snowpea', 3, 1);
|
||||||
|
addPlant('potatomine', 4, 1, { isArmed: true });
|
||||||
|
|
||||||
|
addPlant('snowpea', 2, 2);
|
||||||
|
addPlant('tallnut', 4, 2);
|
||||||
|
|
||||||
|
addPlant('repeater', 1, 3);
|
||||||
|
addPlant('potatomine', 3, 3, { isArmed: true });
|
||||||
|
addPlant('wallnut', 4, 3);
|
||||||
|
|
||||||
|
addPlant('snowpea', 2, 4);
|
||||||
|
addPlant('chomper', 4, 4);
|
||||||
|
|
||||||
|
} else if (currentStage === 2) {
|
||||||
|
// Stage 2: Night Guard (Mushrooms & Peashooters)
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
addPlant('sunflower', 0, r);
|
||||||
|
}
|
||||||
|
addPlant('peashooter', 1, 0);
|
||||||
|
addPlant('fumeshroom', 3, 0);
|
||||||
|
|
||||||
|
addPlant('snowpea', 2, 1);
|
||||||
|
addPlant('wallnut', 4, 1);
|
||||||
|
|
||||||
|
addPlant('repeater', 1, 2);
|
||||||
|
addPlant('repeater', 3, 2);
|
||||||
|
addPlant('wallnut', 5, 2);
|
||||||
|
|
||||||
|
addPlant('fumeshroom', 2, 3);
|
||||||
|
addPlant('chomper', 4, 3);
|
||||||
|
|
||||||
|
addPlant('peashooter', 1, 4);
|
||||||
|
addPlant('potatomine', 3, 4, { isArmed: true });
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Stage 1: Classic Lawn
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
addPlant('sunflower', 0, r);
|
||||||
|
}
|
||||||
|
addPlant('peashooter', 2, 0);
|
||||||
|
|
||||||
|
addPlant('repeater', 2, 1);
|
||||||
|
addPlant('wallnut', 4, 1);
|
||||||
|
|
||||||
|
addPlant('snowpea', 1, 2);
|
||||||
|
addPlant('peashooter', 3, 2);
|
||||||
|
|
||||||
|
addPlant('peashooter', 2, 3);
|
||||||
|
addPlant('wallnut', 4, 3);
|
||||||
|
|
||||||
|
addPlant('repeater', 1, 4);
|
||||||
|
addPlant('potatomine', 3, 4, { isArmed: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row 0 defense: Peashooter at col 2
|
|
||||||
const b02 = this.grid.getCellBounds(2, 0);
|
|
||||||
const p02 = new window.Plant('peashooter', 2, 0, b02.x, b02.y, b02.w, b02.h);
|
|
||||||
this.grid.setPlant(2, 0, p02);
|
|
||||||
this.plants.push(p02);
|
|
||||||
|
|
||||||
// Row 1 defense: Repeater at col 2, Wallnut at col 4
|
|
||||||
const b12 = this.grid.getCellBounds(2, 1);
|
|
||||||
const p12 = new window.Plant('repeater', 2, 1, b12.x, b12.y, b12.w, b12.h);
|
|
||||||
this.grid.setPlant(2, 1, p12);
|
|
||||||
this.plants.push(p12);
|
|
||||||
|
|
||||||
const b14 = this.grid.getCellBounds(4, 1);
|
|
||||||
const p14 = new window.Plant('wallnut', 4, 1, b14.x, b14.y, b14.w, b14.h);
|
|
||||||
this.grid.setPlant(4, 1, p14);
|
|
||||||
this.plants.push(p14);
|
|
||||||
|
|
||||||
// Row 2 defense: Snowpea at col 1, Peashooter at col 3
|
|
||||||
const b21 = this.grid.getCellBounds(1, 2);
|
|
||||||
const p21 = new window.Plant('snowpea', 1, 2, b21.x, b21.y, b21.w, b21.h);
|
|
||||||
this.grid.setPlant(1, 2, p21);
|
|
||||||
this.plants.push(p21);
|
|
||||||
|
|
||||||
const b23 = this.grid.getCellBounds(3, 2);
|
|
||||||
const p23 = new window.Plant('peashooter', 3, 2, b23.x, b23.y, b23.w, b23.h);
|
|
||||||
this.grid.setPlant(3, 2, p23);
|
|
||||||
this.plants.push(p23);
|
|
||||||
|
|
||||||
// Row 3 defense: Peashooter at col 2, Wallnut at col 4
|
|
||||||
const b32 = this.grid.getCellBounds(2, 3);
|
|
||||||
const p32 = new window.Plant('peashooter', 2, 3, b32.x, b32.y, b32.w, b32.h);
|
|
||||||
this.grid.setPlant(2, 3, p32);
|
|
||||||
this.plants.push(p32);
|
|
||||||
|
|
||||||
const b34 = this.grid.getCellBounds(4, 3);
|
|
||||||
const p34 = new window.Plant('wallnut', 4, 3, b34.x, b34.y, b34.w, b34.h);
|
|
||||||
this.grid.setPlant(4, 3, p34);
|
|
||||||
this.plants.push(p34);
|
|
||||||
|
|
||||||
// Row 4 defense: Repeater at col 1, Potato Mine at col 3 (armed)
|
|
||||||
const b41 = this.grid.getCellBounds(1, 4);
|
|
||||||
const p41 = new window.Plant('repeater', 1, 4, b41.x, b41.y, b41.w, b41.h);
|
|
||||||
this.grid.setPlant(1, 4, p41);
|
|
||||||
this.plants.push(p41);
|
|
||||||
|
|
||||||
const b43 = this.grid.getCellBounds(3, 4);
|
|
||||||
const p43 = new window.Plant('potatomine', 3, 4, b43.x, b43.y, b43.w, b43.h);
|
|
||||||
p43.isArmed = true;
|
|
||||||
p43.armTimer = 15;
|
|
||||||
this.grid.setPlant(3, 4, p43);
|
|
||||||
this.plants.push(p43);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showDaveDialogue(level, mode, onContinue) {
|
showDaveDialogue(level, mode, onContinue) {
|
||||||
@@ -593,6 +766,15 @@ window.GameEngine = class GameEngine {
|
|||||||
const zombie = new window.Zombie(row, type);
|
const zombie = new window.Zombie(row, type);
|
||||||
const bounds = this.grid.getCellBounds(0, row);
|
const bounds = this.grid.getCellBounds(0, row);
|
||||||
zombie.y = bounds.centerY - 5;
|
zombie.y = bounds.centerY - 5;
|
||||||
|
if (this.wave) {
|
||||||
|
if (this.wave.hpMultiplier && this.wave.hpMultiplier !== 1.0) {
|
||||||
|
zombie.maxHp = Math.round(zombie.maxHp * this.wave.hpMultiplier);
|
||||||
|
zombie.hp = Math.round(zombie.hp * this.wave.hpMultiplier);
|
||||||
|
}
|
||||||
|
if (this.wave.speedMultiplier && this.wave.speedMultiplier !== 1.0) {
|
||||||
|
zombie.speed *= this.wave.speedMultiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.zombies.push(zombie);
|
this.zombies.push(zombie);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +843,7 @@ window.GameEngine = class GameEngine {
|
|||||||
this.economy.addCoins(100); // 100 coin bonus for level completion!
|
this.economy.addCoins(100); // 100 coin bonus for level completion!
|
||||||
|
|
||||||
// Check achievement: clean_lawn (all lawnmowers intact)
|
// Check achievement: clean_lawn (all lawnmowers intact)
|
||||||
if (this.grid.lawnmowers && this.grid.lawnmowers.length > 0 && this.grid.lawnmowers.every(lm => lm && lm.active)) {
|
if (this.grid.lawnmowers && this.grid.lawnmowers.length > 0 && this.grid.lawnmowers.every(lm => lm && !lm.used && !lm.active)) {
|
||||||
if (this.achievements) this.achievements.trigger('clean_lawn');
|
if (this.achievements) this.achievements.trigger('clean_lawn');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,6 +872,27 @@ window.GameEngine = class GameEngine {
|
|||||||
|
|
||||||
// Save level progression
|
// Save level progression
|
||||||
let levelReward = 100;
|
let levelReward = 100;
|
||||||
|
let izombieNextUnlocked = false;
|
||||||
|
let izombieCurrentStage = 1;
|
||||||
|
let izombieRewardCoins = 150;
|
||||||
|
|
||||||
|
if (this.currentMode === 'izombie' && this.zombieLab) {
|
||||||
|
izombieCurrentStage = this.zombieLab.selectedStage || 1;
|
||||||
|
izombieNextUnlocked = this.zombieLab.unlockNextStage(izombieCurrentStage);
|
||||||
|
const stDef = this.zombieLab.stages.find(s => s.id === izombieCurrentStage);
|
||||||
|
if (stDef) {
|
||||||
|
izombieRewardCoins = stDef.reward || 150;
|
||||||
|
if (stDef.crystals && this.workshop) {
|
||||||
|
this.workshop.addMaterial('crystals', stDef.crystals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.workshop) {
|
||||||
|
this.workshop.addMaterial('scrapMetal', 6);
|
||||||
|
this.workshop.addMaterial('bioHumus', 4);
|
||||||
|
}
|
||||||
|
this.economy.addCoins(Math.max(0, izombieRewardCoins - 100));
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentMode === 'adventure') {
|
if (this.currentMode === 'adventure') {
|
||||||
const campLevel = window.CampaignSystem ? window.CampaignSystem.getLevel(this.currentLevel) : null;
|
const campLevel = window.CampaignSystem ? window.CampaignSystem.getLevel(this.currentLevel) : null;
|
||||||
if (campLevel && campLevel.reward) {
|
if (campLevel && campLevel.reward) {
|
||||||
@@ -726,7 +929,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const statsEl = document.getElementById('victory-stats');
|
const statsEl = document.getElementById('victory-stats');
|
||||||
if (statsEl) {
|
if (statsEl) {
|
||||||
if (this.currentMode === 'izombie') {
|
if (this.currentMode === 'izombie') {
|
||||||
statsEl.innerHTML = `Победа! Все подсолнухи съедены! Мозги захвачены!<br>Награда: <strong>+100 💰 монет</strong> (Всего: ${this.economy.totalCoins})`;
|
statsEl.innerHTML = `Победа! Все цели уничтожены! Мозги захвачены!<br>Этап «Я, Зомби»: <strong>Этап ${izombieCurrentStage} из 5</strong>${izombieNextUnlocked ? ' (🎉 Открыт следующий этап!)' : ''}<br>Награда: <strong>+${izombieRewardCoins} 💰 монет</strong>, <strong>+6 ⚙️ деталей</strong>, <strong>+4 🧪 биогумуса</strong> (Всего: ${this.economy.totalCoins})`;
|
||||||
} else if (this.currentMode === 'bossrush') {
|
} else if (this.currentMode === 'bossrush') {
|
||||||
statsEl.innerHTML = `Триумф! Режим Boss Rush успешно завершён!<br>Награда: <strong>+500 💰 монет</strong> • <strong>+2 💎 кристалла</strong> (Всего: ${this.economy.totalCoins})`;
|
statsEl.innerHTML = `Триумф! Режим Boss Rush успешно завершён!<br>Награда: <strong>+500 💰 монет</strong> • <strong>+2 💎 кристалла</strong> (Всего: ${this.economy.totalCoins})`;
|
||||||
} else if (this.currentMode === 'adventure' && window.CampaignSystem) {
|
} else if (this.currentMode === 'adventure' && window.CampaignSystem) {
|
||||||
@@ -1174,6 +1377,11 @@ window.GameEngine = class GameEngine {
|
|||||||
authModal.classList.remove('hidden');
|
authModal.classList.remove('hidden');
|
||||||
if (authUser && window.PvZAuth && window.PvZAuth.isGuest()) {
|
if (authUser && window.PvZAuth && window.PvZAuth.isGuest()) {
|
||||||
setTimeout(() => authUser.focus(), 50);
|
setTimeout(() => authUser.focus(), 50);
|
||||||
|
} else if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||||
|
window.PvZAuth.fetchProfile().then(() => {
|
||||||
|
this.loadUserData();
|
||||||
|
this.updateProfileUI();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1368,6 +1576,49 @@ window.GameEngine = class GameEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active Sessions event listeners
|
||||||
|
const btnRefreshSessions = document.getElementById('btn-refresh-sessions');
|
||||||
|
if (btnRefreshSessions) {
|
||||||
|
btnRefreshSessions.addEventListener('click', async () => {
|
||||||
|
btnRefreshSessions.classList.add('rotating');
|
||||||
|
await this.renderUserSessionsUI();
|
||||||
|
setTimeout(() => btnRefreshSessions.classList.remove('rotating'), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnTermOthers = document.getElementById('btn-terminate-other-sessions');
|
||||||
|
if (btnTermOthers) {
|
||||||
|
btnTermOthers.addEventListener('click', async () => {
|
||||||
|
if (confirm('Завершить сессии на всех других устройствах?')) {
|
||||||
|
btnTermOthers.disabled = true;
|
||||||
|
btnTermOthers.textContent = '⏳ Завершение...';
|
||||||
|
const res = await window.PvZAuth.terminateOtherSessions();
|
||||||
|
btnTermOthers.disabled = false;
|
||||||
|
btnTermOthers.textContent = '🔒 Завершить сессии на всех других устройствах';
|
||||||
|
if (res && res.success) {
|
||||||
|
if (window.PvZAuth && window.PvZAuth.showNotification) {
|
||||||
|
window.PvZAuth.showNotification('Все другие сессии завершены!', 'Осталось только текущее устройство', '🔒');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.PvZAuth && window.PvZAuth.showNotification) {
|
||||||
|
window.PvZAuth.showNotification('Ошибка', res?.message || 'Не удалось завершить сессии', '❌');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.renderUserSessionsUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time Profile change event subscription
|
||||||
|
if (window.PvZAuth && typeof window.PvZAuth.onProfileChange === 'function') {
|
||||||
|
window.PvZAuth.onProfileChange(() => {
|
||||||
|
this.loadUserData();
|
||||||
|
this.updateProfileUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
this.updateShopUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-save on page hide / tab switch / page unload
|
// Auto-save on page hide / tab switch / page unload
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') {
|
if (document.visibilityState === 'hidden') {
|
||||||
@@ -1523,8 +1774,10 @@ window.GameEngine = class GameEngine {
|
|||||||
const btnCloseWorkshop = document.getElementById('btn-close-workshop');
|
const btnCloseWorkshop = document.getElementById('btn-close-workshop');
|
||||||
const tabPlants = document.getElementById('tab-plants');
|
const tabPlants = document.getElementById('tab-plants');
|
||||||
const tabPerks = document.getElementById('tab-perks');
|
const tabPerks = document.getElementById('tab-perks');
|
||||||
|
const tabZombies = document.getElementById('tab-zombies');
|
||||||
const contentPlants = document.getElementById('ws-content-plants');
|
const contentPlants = document.getElementById('ws-content-plants');
|
||||||
const contentPerks = document.getElementById('ws-content-perks');
|
const contentPerks = document.getElementById('ws-content-perks');
|
||||||
|
const contentZombies = document.getElementById('ws-content-zombies');
|
||||||
|
|
||||||
const openWorkshop = () => {
|
const openWorkshop = () => {
|
||||||
this.updateWorkshopUI();
|
this.updateWorkshopUI();
|
||||||
@@ -1540,18 +1793,194 @@ window.GameEngine = class GameEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabPlants && tabPerks && contentPlants && contentPerks) {
|
const switchWorkshopTab = (tab) => {
|
||||||
tabPlants.addEventListener('click', () => {
|
if (tabPlants) tabPlants.classList.toggle('active', tab === 'plants');
|
||||||
tabPlants.classList.add('active');
|
if (tabPerks) tabPerks.classList.toggle('active', tab === 'perks');
|
||||||
tabPerks.classList.remove('active');
|
if (tabZombies) tabZombies.classList.toggle('active', tab === 'zombies');
|
||||||
contentPlants.classList.remove('hidden');
|
|
||||||
contentPerks.classList.add('hidden');
|
if (contentPlants) contentPlants.classList.toggle('hidden', tab !== 'plants');
|
||||||
|
if (contentPerks) contentPerks.classList.toggle('hidden', tab !== 'perks');
|
||||||
|
if (contentZombies) contentZombies.classList.toggle('hidden', tab !== 'zombies');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tabPlants) tabPlants.addEventListener('click', () => switchWorkshopTab('plants'));
|
||||||
|
if (tabPerks) tabPerks.addEventListener('click', () => switchWorkshopTab('perks'));
|
||||||
|
if (tabZombies) tabZombies.addEventListener('click', () => switchWorkshopTab('zombies'));
|
||||||
|
|
||||||
|
// Casino Modal Handlers
|
||||||
|
const casinoModal = document.getElementById('casino-modal');
|
||||||
|
const btnCasinoStart = document.getElementById('btn-casino-start');
|
||||||
|
const btnCasinoDrawer = document.getElementById('btn-drawer-casino');
|
||||||
|
const btnCloseCasino = document.getElementById('btn-close-casino');
|
||||||
|
const tabCasinoSlots = document.getElementById('tab-casino-slots');
|
||||||
|
const tabCasinoBank = document.getElementById('tab-casino-bank');
|
||||||
|
const contentCasinoSlots = document.getElementById('casino-content-slots');
|
||||||
|
const contentCasinoBank = document.getElementById('casino-content-bank');
|
||||||
|
|
||||||
|
const openCasino = () => {
|
||||||
|
this.updateCasinoUI();
|
||||||
|
if (casinoModal) casinoModal.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (btnCasinoStart) btnCasinoStart.addEventListener('click', openCasino);
|
||||||
|
if (btnCasinoDrawer) btnCasinoDrawer.addEventListener('click', () => {
|
||||||
|
openCasino();
|
||||||
|
const drawer = document.getElementById('mobile-drawer');
|
||||||
|
if (drawer) drawer.classList.add('hidden');
|
||||||
|
});
|
||||||
|
if (btnCloseCasino && casinoModal) {
|
||||||
|
btnCloseCasino.addEventListener('click', () => {
|
||||||
|
casinoModal.classList.add('hidden');
|
||||||
|
this.updateRecordDisplay();
|
||||||
});
|
});
|
||||||
tabPerks.addEventListener('click', () => {
|
}
|
||||||
tabPerks.classList.add('active');
|
|
||||||
tabPlants.classList.remove('active');
|
if (tabCasinoSlots && tabCasinoBank) {
|
||||||
contentPerks.classList.remove('hidden');
|
tabCasinoSlots.addEventListener('click', () => {
|
||||||
contentPlants.classList.add('hidden');
|
tabCasinoSlots.classList.add('active');
|
||||||
|
tabCasinoBank.classList.remove('active');
|
||||||
|
if (contentCasinoSlots) contentCasinoSlots.classList.remove('hidden');
|
||||||
|
if (contentCasinoBank) contentCasinoBank.classList.add('hidden');
|
||||||
|
});
|
||||||
|
tabCasinoBank.addEventListener('click', () => {
|
||||||
|
tabCasinoBank.classList.add('active');
|
||||||
|
tabCasinoSlots.classList.remove('active');
|
||||||
|
if (contentCasinoBank) contentCasinoBank.classList.remove('hidden');
|
||||||
|
if (contentCasinoSlots) contentCasinoSlots.classList.add('hidden');
|
||||||
|
this.updateCasinoUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Casino Bet Buttons
|
||||||
|
const betBtns = document.querySelectorAll('.btn-bet');
|
||||||
|
betBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
betBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const betVal = parseInt(btn.getAttribute('data-bet') || '50', 10);
|
||||||
|
if (this.casino) this.casino.currentBet = betVal;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Casino Spin Button
|
||||||
|
const btnSpin = document.getElementById('btn-spin-slots');
|
||||||
|
const reel1 = document.getElementById('reel-1');
|
||||||
|
const reel2 = document.getElementById('reel-2');
|
||||||
|
const reel3 = document.getElementById('reel-3');
|
||||||
|
const resultBanner = document.getElementById('slot-result-banner');
|
||||||
|
|
||||||
|
if (btnSpin) {
|
||||||
|
btnSpin.addEventListener('click', () => {
|
||||||
|
if (!this.casino) return;
|
||||||
|
if (this.casino.isSpinning) return;
|
||||||
|
const bet = this.casino.currentBet || 50;
|
||||||
|
if (this.economy.totalCoins < bet) {
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = '❌ Недостаточно монет для ставки! Возьмите займ в банке.';
|
||||||
|
resultBanner.style.color = '#ef5350';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate spin
|
||||||
|
[reel1, reel2, reel3].forEach(r => { if (r) r.classList.add('spinning'); });
|
||||||
|
if (btnSpin) btnSpin.disabled = true;
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = '🎰 Барабаны вращаются...';
|
||||||
|
resultBanner.style.color = '#ffca28';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.casino) {
|
||||||
|
this.casino.isSpinning = false;
|
||||||
|
const res = this.casino.spin(bet);
|
||||||
|
[reel1, reel2, reel3].forEach(r => { if (r) r.classList.remove('spinning'); });
|
||||||
|
if (btnSpin) btnSpin.disabled = false;
|
||||||
|
|
||||||
|
if (res.icons) {
|
||||||
|
if (reel1) reel1.innerHTML = `<span class="reel-symbol">${res.icons[0]}</span>`;
|
||||||
|
if (reel2) reel2.innerHTML = `<span class="reel-symbol">${res.icons[1]}</span>`;
|
||||||
|
if (reel3) reel3.innerHTML = `<span class="reel-symbol">${res.icons[2]}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = res.message || '';
|
||||||
|
if (res.isJackpot) resultBanner.style.color = '#ffd700';
|
||||||
|
else if (res.payout > 0) resultBanner.style.color = '#69f0ae';
|
||||||
|
else resultBanner.style.color = '#b0bec5';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCasinoUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bank Deposit
|
||||||
|
const btnDeposit = document.getElementById('btn-bank-deposit');
|
||||||
|
const inputDeposit = document.getElementById('input-deposit-amount');
|
||||||
|
if (btnDeposit) {
|
||||||
|
btnDeposit.addEventListener('click', () => {
|
||||||
|
if (!this.casino) return;
|
||||||
|
const amt = parseInt(inputDeposit ? inputDeposit.value : '0', 10);
|
||||||
|
const res = this.casino.depositCoins(amt);
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = res.message;
|
||||||
|
resultBanner.style.color = res.success ? '#69f0ae' : '#ef5350';
|
||||||
|
}
|
||||||
|
if (inputDeposit && res.success) inputDeposit.value = '';
|
||||||
|
this.updateCasinoUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bank Withdraw
|
||||||
|
const btnWithdraw = document.getElementById('btn-bank-withdraw');
|
||||||
|
if (btnWithdraw) {
|
||||||
|
btnWithdraw.addEventListener('click', () => {
|
||||||
|
if (!this.casino) return;
|
||||||
|
const res = this.casino.withdrawDeposit();
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = res.message;
|
||||||
|
resultBanner.style.color = res.success ? '#69f0ae' : '#ef5350';
|
||||||
|
}
|
||||||
|
this.updateCasinoUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bank Take Loan
|
||||||
|
const btnTakeLoan = document.getElementById('btn-bank-take-loan');
|
||||||
|
const inputLoan = document.getElementById('input-loan-amount');
|
||||||
|
if (btnTakeLoan) {
|
||||||
|
btnTakeLoan.addEventListener('click', () => {
|
||||||
|
if (!this.casino) return;
|
||||||
|
const amt = parseInt(inputLoan ? inputLoan.value : '0', 10);
|
||||||
|
const res = this.casino.takeLoan(amt);
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = res.message;
|
||||||
|
resultBanner.style.color = res.success ? '#69f0ae' : '#ef5350';
|
||||||
|
}
|
||||||
|
this.updateCasinoUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bank Repay Loan
|
||||||
|
const btnRepayLoan = document.getElementById('btn-bank-repay-loan');
|
||||||
|
if (btnRepayLoan) {
|
||||||
|
btnRepayLoan.addEventListener('click', () => {
|
||||||
|
if (!this.casino) return;
|
||||||
|
const amt = parseInt(inputLoan && inputLoan.value ? inputLoan.value : String(this.casino.loan), 10);
|
||||||
|
const res = this.casino.repayLoan(amt);
|
||||||
|
if (resultBanner) {
|
||||||
|
resultBanner.textContent = res.message;
|
||||||
|
resultBanner.style.color = res.success ? '#69f0ae' : '#ef5350';
|
||||||
|
}
|
||||||
|
if (inputLoan && res.success) inputLoan.value = '';
|
||||||
|
this.updateCasinoUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2085,7 +2514,9 @@ window.GameEngine = class GameEngine {
|
|||||||
window.PLANT_CATALOGUE.forEach(p => {
|
window.PLANT_CATALOGUE.forEach(p => {
|
||||||
const isBasic = this.economy.defaultUnlocked.includes(p.id);
|
const isBasic = this.economy.defaultUnlocked.includes(p.id);
|
||||||
const isUnlocked = this.economy.isPlantUnlocked(p.id);
|
const isUnlocked = this.economy.isPlantUnlocked(p.id);
|
||||||
const canAfford = this.economy.totalCoins >= (p.price || 0);
|
const price = window.PvZConfig ? window.PvZConfig.getPlantShopPrice(p.id) : (p.price || 0);
|
||||||
|
const sunCost = window.PvZConfig ? window.PvZConfig.getPlantSunCost(p.id, p.cost) : p.cost;
|
||||||
|
const canAfford = this.economy.totalCoins >= price;
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'shop-plant-card';
|
card.className = 'shop-plant-card';
|
||||||
@@ -2096,14 +2527,14 @@ window.GameEngine = class GameEngine {
|
|||||||
} else if (isUnlocked) {
|
} else if (isUnlocked) {
|
||||||
actionHtml = '<span class="badge-purchased">Куплено ✅</span>';
|
actionHtml = '<span class="badge-purchased">Куплено ✅</span>';
|
||||||
} else {
|
} else {
|
||||||
actionHtml = `<button type="button" class="btn-buy" data-plant-id="${p.id}" ${canAfford ? '' : 'disabled'}>Купить (${p.price} 💰)</button>`;
|
actionHtml = `<button type="button" class="btn-buy" data-plant-id="${p.id}" ${canAfford ? '' : 'disabled'}>Купить (${price} 💰)</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="shop-plant-icon">${p.icon}</div>
|
<div class="shop-plant-icon">${p.icon}</div>
|
||||||
<div class="shop-plant-info">
|
<div class="shop-plant-info">
|
||||||
<h4>${p.name}</h4>
|
<h4>${p.name}</h4>
|
||||||
<div class="shop-plant-cost">☀️ ${p.cost} солнца</div>
|
<div class="shop-plant-cost">☀️ ${sunCost} солнца</div>
|
||||||
<p>${p.desc}</p>
|
<p>${p.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>${actionHtml}</div>
|
<div>${actionHtml}</div>
|
||||||
@@ -2310,6 +2741,193 @@ window.GameEngine = class GameEngine {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Zombie Lab mutations & campaign stages
|
||||||
|
this.renderZombieLabUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderZombieLabUI() {
|
||||||
|
const container = document.getElementById('zombies-upgrade-list');
|
||||||
|
if (!container || !this.zombieLab) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Campaign Stage Selector Banner
|
||||||
|
const stageBanner = document.createElement('div');
|
||||||
|
stageBanner.className = 'upgrade-card';
|
||||||
|
stageBanner.style.gridColumn = '1 / -1';
|
||||||
|
stageBanner.style.background = 'linear-gradient(135deg, rgba(76, 175, 80, 0.15), rgba(38, 50, 56, 0.95))';
|
||||||
|
stageBanner.style.border = '1px solid rgba(76, 175, 80, 0.5)';
|
||||||
|
|
||||||
|
const selStage = this.zombieLab.selectedStage || 1;
|
||||||
|
const unlStage = this.zombieLab.unlockedStage || 1;
|
||||||
|
|
||||||
|
let stageBtnsHtml = '';
|
||||||
|
this.zombieLab.stages.forEach(st => {
|
||||||
|
const isUnlocked = st.id <= unlStage;
|
||||||
|
const isSelected = st.id === selStage;
|
||||||
|
const activeStyle = isSelected ? 'border: 2px solid #ffeb3b; background: rgba(255, 235, 59, 0.25); color: #fff;' : '';
|
||||||
|
const lockIcon = isUnlocked ? (st.isBoss ? '👑' : '🧠') : '🔒';
|
||||||
|
stageBtnsHtml += `
|
||||||
|
<button class="btn btn-small btn-stage-select ${isSelected ? 'active' : ''}" data-stage-id="${st.id}" ${isUnlocked ? '' : 'disabled'} style="margin: 3px; font-size: 11px; padding: 5px 9px; ${activeStyle}">
|
||||||
|
${lockIcon} Этап ${st.id} ${st.isBoss ? '(БОСС)' : ''}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const curStageDef = this.zombieLab.stages.find(s => s.id === selStage) || this.zombieLab.stages[0];
|
||||||
|
|
||||||
|
stageBanner.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0; color: #a5d6a7; font-size: 15px;">🗺️ Кампания «Я, Зомби» (Этап ${selStage} из 5)</h3>
|
||||||
|
<p style="margin: 3px 0 0 0; font-size: 12px; color: #cfd8dc;">${curStageDef.title}: ${curStageDef.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||||
|
${stageBtnsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(stageBanner);
|
||||||
|
|
||||||
|
// Bind stage select buttons
|
||||||
|
stageBanner.querySelectorAll('.btn-stage-select').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const stId = parseInt(e.currentTarget.getAttribute('data-stage-id'), 10);
|
||||||
|
this.zombieLab.selectedStage = stId;
|
||||||
|
this.renderZombieLabUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zombie Mutation Cards
|
||||||
|
for (const [key, zDef] of Object.entries(this.zombieLab.zombies)) {
|
||||||
|
const curTier = this.zombieLab.getZombieLevel(key);
|
||||||
|
const isMax = curTier >= 3;
|
||||||
|
const curData = zDef.tiers[curTier] || zDef.tiers[1];
|
||||||
|
const nextData = isMax ? null : zDef.tiers[curTier + 1];
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'upgrade-card zombie-tier-card';
|
||||||
|
|
||||||
|
let costHtml = '';
|
||||||
|
if (nextData && nextData.cost) {
|
||||||
|
const parts = [];
|
||||||
|
if (nextData.cost.coins) parts.push(`💰 ${nextData.cost.coins}`);
|
||||||
|
if (nextData.cost.scrapMetal) parts.push(`⚙️ ${nextData.cost.scrapMetal}`);
|
||||||
|
if (nextData.cost.bioHumus) parts.push(`🧪 ${nextData.cost.bioHumus}`);
|
||||||
|
if (nextData.cost.crystals) parts.push(`💎 ${nextData.cost.crystals}`);
|
||||||
|
costHtml = parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stars = '★'.repeat(curTier) + '☆'.repeat(3 - curTier);
|
||||||
|
const canAfford = this.zombieLab.canUpgrade(key);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="upgrade-card-header">
|
||||||
|
<div class="upgrade-plant-icon">${zDef.icon}</div>
|
||||||
|
<div class="upgrade-title-box">
|
||||||
|
<h4>${zDef.name}</h4>
|
||||||
|
<div class="upgrade-stars" style="color: #69f0ae;">${stars} (Ур. ${curTier}/3)</div>
|
||||||
|
</div>
|
||||||
|
<div class="zombie-tier-badge">Ранг ${curTier}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upgrade-stats-current" style="font-size: 12px; color: #cfd8dc; margin-bottom: 6px;">
|
||||||
|
<strong>Текущий бонус:</strong> ${curData.title} — ${curData.desc}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!isMax ? `
|
||||||
|
<div class="upgrade-stats-next" style="font-size: 12px; color: #a5d6a7; margin-bottom: 8px; border-left-color: #69f0ae;">
|
||||||
|
<strong>Мутация до Ур. ${curTier + 1}:</strong> ${nextData.title} — ${nextData.desc}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-upgrade" data-upgrade-zombie="${key}" ${canAfford ? '' : 'disabled'} style="width: 100%; font-size: 12px; padding: 7px;">
|
||||||
|
Мутировать (${costHtml})
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<div class="upgrade-stats-next" style="border-left-color: #ffd700; color: #ffe082; font-size: 12px;">
|
||||||
|
🧬 Максимальная мутация достигнута!
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind upgrade buttons for zombies
|
||||||
|
container.querySelectorAll('[data-upgrade-zombie]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const zType = e.currentTarget.getAttribute('data-upgrade-zombie');
|
||||||
|
const res = this.zombieLab.upgradeZombie(zType);
|
||||||
|
if (res.success) {
|
||||||
|
this.saveUserData();
|
||||||
|
this.updateWorkshopUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCasinoUI() {
|
||||||
|
if (!this.casino) return;
|
||||||
|
const coinsEl = document.getElementById('casino-coins');
|
||||||
|
if (coinsEl) coinsEl.textContent = (this.economy ? this.economy.totalCoins : 0).toLocaleString();
|
||||||
|
|
||||||
|
const depStat = document.getElementById('bank-deposit-stat');
|
||||||
|
if (depStat) depStat.textContent = this.casino.deposit.toLocaleString();
|
||||||
|
|
||||||
|
const intStat = document.getElementById('bank-interest-stat');
|
||||||
|
if (intStat) intStat.textContent = `+${this.casino.getAccruedInterest().toLocaleString()}`;
|
||||||
|
|
||||||
|
const debtStat = document.getElementById('bank-debt-stat');
|
||||||
|
if (debtStat) debtStat.textContent = this.casino.loan.toLocaleString();
|
||||||
|
|
||||||
|
const depVal = document.getElementById('bank-deposit-val');
|
||||||
|
if (depVal) depVal.textContent = this.casino.deposit.toLocaleString();
|
||||||
|
|
||||||
|
const intVal = document.getElementById('bank-interest-val');
|
||||||
|
if (intVal) intVal.textContent = this.casino.getAccruedInterest().toLocaleString();
|
||||||
|
|
||||||
|
const debtVal = document.getElementById('bank-debt-val');
|
||||||
|
if (debtVal) debtVal.textContent = this.casino.loan.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerShrekEasterEgg() {
|
||||||
|
if (window.PvZAudio && window.PvZAudio.playExplosion) {
|
||||||
|
window.PvZAudio.playExplosion();
|
||||||
|
}
|
||||||
|
const canvas = document.getElementById('game-canvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.classList.add('shake');
|
||||||
|
setTimeout(() => canvas.classList.remove('shake'), 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
let affected = 0;
|
||||||
|
if (this.zombies && this.zombies.length > 0) {
|
||||||
|
for (const z of this.zombies) {
|
||||||
|
if (z && z.health > 0) {
|
||||||
|
z.takeDamage(2000);
|
||||||
|
affected++;
|
||||||
|
if (this.combat && this.combat.createSplash) {
|
||||||
|
this.combat.createSplash(z.x, z.y, '#7cb342', 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let toast = document.getElementById('achievement-toast');
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement('div');
|
||||||
|
toast.id = 'achievement-toast';
|
||||||
|
toast.className = 'achievement-toast';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
}
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="ach-toast-icon">🧅</div>
|
||||||
|
<div class="ach-toast-text">
|
||||||
|
<div class="ach-toast-subtitle">«THIS IS MY SWAMP!»</div>
|
||||||
|
<div class="ach-toast-title">ШРЕК СОТРЯС ЗЕМЛЮ! 2000 урона всем зомби! (${affected} поражено)</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
toast.classList.add('visible');
|
||||||
|
setTimeout(() => toast.classList.remove('visible'), 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
initZenGarden() {
|
initZenGarden() {
|
||||||
@@ -2756,6 +3374,16 @@ window.GameEngine = class GameEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
let rowsHtml = '';
|
let rowsHtml = '';
|
||||||
entries.forEach((item, index) => {
|
entries.forEach((item, index) => {
|
||||||
let medal = `#${index + 1}`;
|
let medal = `#${index + 1}`;
|
||||||
@@ -2763,12 +3391,16 @@ window.GameEngine = class GameEngine {
|
|||||||
else if (index === 1) medal = '🥈 2';
|
else if (index === 1) medal = '🥈 2';
|
||||||
else if (index === 2) medal = '🥉 3';
|
else if (index === 2) medal = '🥉 3';
|
||||||
|
|
||||||
|
const safeName = escapeHtml(item.name || 'Игрок');
|
||||||
|
const safeDetail = escapeHtml(item.detail || item.score || '');
|
||||||
|
const safeDate = escapeHtml(item.date || '');
|
||||||
|
|
||||||
rowsHtml += `
|
rowsHtml += `
|
||||||
<tr class="${index < 3 ? 'top-rank' : ''}">
|
<tr class="${index < 3 ? 'top-rank' : ''}">
|
||||||
<td style="font-weight: bold; width: 60px; text-align: center;">${medal}</td>
|
<td style="font-weight: bold; width: 60px; text-align: center;">${medal}</td>
|
||||||
<td style="font-weight: bold; color: #fff;">${item.name}</td>
|
<td style="font-weight: bold; color: #fff;">${safeName}</td>
|
||||||
<td style="color: #ffd54f; font-weight: bold;">${item.detail || item.score}</td>
|
<td style="color: #ffd54f; font-weight: bold;">${safeDetail}</td>
|
||||||
<td style="color: #b0bec5; font-size: 11px; text-align: right;">${item.date || ''}</td>
|
<td style="color: #b0bec5; font-size: 11px; text-align: right;">${safeDate}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -3117,14 +3749,14 @@ window.GameEngine = class GameEngine {
|
|||||||
|
|
||||||
// 9. I, Zombie Win & Defeat Check
|
// 9. I, Zombie Win & Defeat Check
|
||||||
if (this.currentMode === 'izombie' && this.izombieSetupDone && this.isRunning) {
|
if (this.currentMode === 'izombie' && this.izombieSetupDone && this.isRunning) {
|
||||||
const sunflowers = this.plants.filter(p => p.type === 'sunflower');
|
const targets = this.plants.filter(p => p.type === 'sunflower' || p.type === 'mega_sunflower');
|
||||||
if (sunflowers.length === 0) {
|
if (targets.length === 0) {
|
||||||
this.triggerVictory();
|
this.triggerVictory();
|
||||||
} else {
|
} else {
|
||||||
const minZombieCost = 50;
|
const minZombieCost = 50;
|
||||||
const hasAliveZombies = this.zombies.some(z => z.health > 0 && !z.toRemove);
|
const hasAliveZombies = this.zombies.some(z => z.health > 0 && !z.toRemove);
|
||||||
if (!hasAliveZombies && this.economy.sun < minZombieCost) {
|
if (!hasAliveZombies && this.economy.sun < minZombieCost) {
|
||||||
this.triggerGameOver('Закончились солнце и зомби! Подсолнухи уцелели.');
|
this.triggerGameOver('Закончились солнце и зомби! Растения уцелели.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const engine = new window.GameEngine('gameCanvas');
|
const engine = new window.GameEngine('gameCanvas');
|
||||||
|
|
||||||
|
// Immediate authoritative profile sync with server
|
||||||
|
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||||
|
window.PvZAuth.fetchProfile().then(() => {
|
||||||
|
if (engine && typeof engine.loadUserData === 'function') {
|
||||||
|
engine.loadUserData();
|
||||||
|
engine.updateProfileUI();
|
||||||
|
engine.updateRecordDisplay();
|
||||||
|
engine.updateShopUI();
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function startGame() {
|
function startGame() {
|
||||||
if (startModal) {
|
if (startModal) {
|
||||||
startModal.classList.add('hidden');
|
startModal.classList.add('hidden');
|
||||||
|
|||||||
+238
-20
@@ -131,6 +131,52 @@ window.PvZAuth = (function () {
|
|||||||
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
|
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Profile change event listeners ─── */
|
||||||
|
const _profileListeners = new Set();
|
||||||
|
function onProfileChange(fn) {
|
||||||
|
if (typeof fn === 'function') _profileListeners.add(fn);
|
||||||
|
}
|
||||||
|
function offProfileChange(fn) {
|
||||||
|
_profileListeners.delete(fn);
|
||||||
|
}
|
||||||
|
function notifyProfileChange(data) {
|
||||||
|
_profileListeners.forEach(fn => {
|
||||||
|
try { fn(data); } catch (e) { console.error('Profile listener error:', e); }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('pvz:profile-updated', { detail: data }));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Non-blocking GET server call with Bearer Token Authorization ─── */
|
||||||
|
async function apiGet(endpoint) {
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE}/${endpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
const json = await resp.json().catch(() => ({}));
|
||||||
|
return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await resp.json();
|
||||||
|
return json;
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: 'Сервер авторизации недоступен' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Non-blocking server call with Bearer Token Authorization ─── */
|
/* ─── Non-blocking server call with Bearer Token Authorization ─── */
|
||||||
async function apiPost(endpoint, body = {}, keepalive = false) {
|
async function apiPost(endpoint, body = {}, keepalive = false) {
|
||||||
try {
|
try {
|
||||||
@@ -172,41 +218,90 @@ window.PvZAuth = (function () {
|
|||||||
|
|
||||||
/* ─── Debounced save (prevents save storms on rapid updates) ─── */
|
/* ─── Debounced save (prevents save storms on rapid updates) ─── */
|
||||||
let _saveTimer = null;
|
let _saveTimer = null;
|
||||||
|
let _hasSyncedFromServer = false;
|
||||||
|
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
if (_saveTimer) clearTimeout(_saveTimer);
|
if (_saveTimer) clearTimeout(_saveTimer);
|
||||||
_saveTimer = setTimeout(() => {
|
_saveTimer = setTimeout(async () => {
|
||||||
const user = localStorage.getItem(CURRENT_USER_KEY);
|
const user = localStorage.getItem(CURRENT_USER_KEY);
|
||||||
if (!user || user === 'Гость' || !_cachedData) return;
|
if (!user || user === 'Гость' || !_cachedData) return;
|
||||||
|
if (!_hasSyncedFromServer && getToken()) {
|
||||||
|
await authInstance.fetchProfile();
|
||||||
|
}
|
||||||
apiPost('save', { username: user, data: _cachedData });
|
apiPost('save', { username: user, data: _cachedData });
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeUserData(serverData) {
|
function mergeUserData(serverData, localData = null) {
|
||||||
const def = getDefaultData();
|
const def = getDefaultData();
|
||||||
if (!serverData) return def;
|
if (!serverData && !localData) return def;
|
||||||
|
const base = serverData || localData || def;
|
||||||
|
const loc = localData || {};
|
||||||
|
|
||||||
|
// Combine unlocked plants uniquely
|
||||||
|
const unlocked = Array.from(new Set([
|
||||||
|
...def.unlockedPlants,
|
||||||
|
...(loc.unlockedPlants || []),
|
||||||
|
...(base.unlockedPlants || [])
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Combine selected deck
|
||||||
|
const selDeck = (base.selectedDeck && base.selectedDeck.length > 0)
|
||||||
|
? base.selectedDeck
|
||||||
|
: ((loc.selectedDeck && loc.selectedDeck.length > 0) ? loc.selectedDeck : def.selectedDeck);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...def,
|
...def,
|
||||||
...serverData,
|
...loc,
|
||||||
|
...base,
|
||||||
|
coins: base.coins !== undefined ? base.coins : (loc.coins !== undefined ? loc.coins : def.coins),
|
||||||
|
level: Math.max(base.level || 1, loc.level || 1),
|
||||||
|
maxUnlockedLevel: Math.max(base.maxUnlockedLevel || 1, loc.maxUnlockedLevel || 1, base.level || 1, loc.level || 1),
|
||||||
|
highScore: Math.max(base.highScore || 0, loc.highScore || 0),
|
||||||
|
theme: base.theme || loc.theme || def.theme,
|
||||||
stats: {
|
stats: {
|
||||||
...def.stats,
|
...def.stats,
|
||||||
...(serverData.stats || {})
|
...(loc.stats || {}),
|
||||||
|
...(base.stats || {})
|
||||||
},
|
},
|
||||||
upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) },
|
upgrades: {
|
||||||
unlockedPlants: serverData.unlockedPlants || def.unlockedPlants,
|
...def.upgrades,
|
||||||
selectedDeck: serverData.selectedDeck || def.selectedDeck,
|
...(loc.upgrades || {}),
|
||||||
materials: { ...def.materials, ...(serverData.materials || {}) },
|
...(base.upgrades || {}),
|
||||||
plantLevels: { ...def.plantLevels, ...(serverData.plantLevels || {}) },
|
unlockedPlants: unlocked,
|
||||||
perks: { ...def.perks, ...(serverData.perks || {}) },
|
selectedDeck: selDeck
|
||||||
zenGarden: serverData.zenGarden
|
},
|
||||||
|
unlockedPlants: unlocked,
|
||||||
|
selectedDeck: selDeck,
|
||||||
|
materials: {
|
||||||
|
...def.materials,
|
||||||
|
...(loc.materials || {}),
|
||||||
|
...(base.materials || {})
|
||||||
|
},
|
||||||
|
plantLevels: {
|
||||||
|
...def.plantLevels,
|
||||||
|
...(loc.plantLevels || {}),
|
||||||
|
...(base.plantLevels || {})
|
||||||
|
},
|
||||||
|
perks: {
|
||||||
|
...def.perks,
|
||||||
|
...(loc.perks || {}),
|
||||||
|
...(base.perks || {})
|
||||||
|
},
|
||||||
|
zenGarden: base.zenGarden
|
||||||
? {
|
? {
|
||||||
...def.zenGarden,
|
...def.zenGarden,
|
||||||
...serverData.zenGarden,
|
...(loc.zenGarden || {}),
|
||||||
pots: serverData.zenGarden.pots || def.zenGarden.pots,
|
...base.zenGarden,
|
||||||
snail: { ...def.zenGarden.snail, ...(serverData.zenGarden.snail || {}) }
|
pots: base.zenGarden.pots || (loc.zenGarden && loc.zenGarden.pots) || def.zenGarden.pots,
|
||||||
|
snail: { ...def.zenGarden.snail, ...((loc.zenGarden && loc.zenGarden.snail) || {}), ...(base.zenGarden.snail || {}) }
|
||||||
}
|
}
|
||||||
: def.zenGarden,
|
: (loc.zenGarden || def.zenGarden),
|
||||||
treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom,
|
treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom,
|
||||||
achievements: serverData.achievements || {}
|
achievements: {
|
||||||
|
...(loc.achievements || {}),
|
||||||
|
...(base.achievements || {})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,9 +323,13 @@ window.PvZAuth = (function () {
|
|||||||
getDefaultData,
|
getDefaultData,
|
||||||
validateUsername,
|
validateUsername,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
|
apiGet,
|
||||||
apiPost,
|
apiPost,
|
||||||
scheduleSave,
|
scheduleSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
|
onProfileChange,
|
||||||
|
offProfileChange,
|
||||||
|
notifyProfileChange,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Login
|
* User Login
|
||||||
@@ -263,7 +362,11 @@ window.PvZAuth = (function () {
|
|||||||
|
|
||||||
if (window.GameEngine && window._pvzEngine) {
|
if (window.GameEngine && window._pvzEngine) {
|
||||||
window._pvzEngine.loadUserData();
|
window._pvzEngine.loadUserData();
|
||||||
|
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
|
||||||
|
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
|
||||||
|
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
|
||||||
}
|
}
|
||||||
|
notifyProfileChange(_cachedData);
|
||||||
if (typeof callback === 'function') callback(true, _cachedData, null);
|
if (typeof callback === 'function') callback(true, _cachedData, null);
|
||||||
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
|
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
|
||||||
} else {
|
} else {
|
||||||
@@ -305,7 +408,11 @@ window.PvZAuth = (function () {
|
|||||||
|
|
||||||
if (window.GameEngine && window._pvzEngine) {
|
if (window.GameEngine && window._pvzEngine) {
|
||||||
window._pvzEngine.loadUserData();
|
window._pvzEngine.loadUserData();
|
||||||
|
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
|
||||||
|
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
|
||||||
|
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
|
||||||
}
|
}
|
||||||
|
notifyProfileChange(_cachedData);
|
||||||
if (typeof callback === 'function') callback(true, _cachedData, null);
|
if (typeof callback === 'function') callback(true, _cachedData, null);
|
||||||
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
|
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
|
||||||
} else {
|
} else {
|
||||||
@@ -318,6 +425,7 @@ window.PvZAuth = (function () {
|
|||||||
loginAsGuest() {
|
loginAsGuest() {
|
||||||
clearToken();
|
clearToken();
|
||||||
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
||||||
|
notifyProfileChange(this.getUserData());
|
||||||
return { success: true, user: 'Гость' };
|
return { success: true, user: 'Гость' };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -334,16 +442,118 @@ window.PvZAuth = (function () {
|
|||||||
this.loginAsGuest();
|
this.loginAsGuest();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly Server-Authoritative Profile Sync:
|
||||||
|
* When user is logged in, fetches /api/auth/profile (with fallback to /api/users)
|
||||||
|
* and applies it directly to the game state.
|
||||||
|
*/
|
||||||
|
async fetchProfile() {
|
||||||
|
const token = getToken();
|
||||||
|
const user = this.getCurrentUser();
|
||||||
|
if (!token || user === 'Гость') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let profileData = null;
|
||||||
|
|
||||||
|
// 1. Try dedicated profile endpoint
|
||||||
|
const res = await apiGet('profile');
|
||||||
|
if (res && res.success && res.data) {
|
||||||
|
profileData = res.data;
|
||||||
|
} else if (res && res.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to /api/users which is universally supported across backend versions
|
||||||
|
if (!profileData) {
|
||||||
|
try {
|
||||||
|
const usersResp = await fetch('/api/users');
|
||||||
|
if (usersResp.ok) {
|
||||||
|
const usersJson = await usersResp.json();
|
||||||
|
if (usersJson && usersJson.success && Array.isArray(usersJson.users)) {
|
||||||
|
const matched = usersJson.users.find(u => u.username && u.username.toLowerCase() === user.toLowerCase());
|
||||||
|
if (matched) {
|
||||||
|
profileData = matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileData) {
|
||||||
|
_hasSyncedFromServer = true;
|
||||||
|
_cachedData = mergeUserData(profileData);
|
||||||
|
localSave(_cachedData);
|
||||||
|
|
||||||
|
if (window._pvzEngine) {
|
||||||
|
window._pvzEngine.loadUserData();
|
||||||
|
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
|
||||||
|
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
|
||||||
|
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
|
||||||
|
}
|
||||||
|
notifyProfileChange(_cachedData);
|
||||||
|
return _cachedData;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Profile fetch from server error:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncFromServer() {
|
||||||
|
return this.fetchProfile();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active Device Sessions Management
|
||||||
|
*/
|
||||||
|
async fetchSessions() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token || this.isGuest()) return [];
|
||||||
|
try {
|
||||||
|
const res = await apiGet('sessions');
|
||||||
|
if (res && res.success && Array.isArray(res.sessions)) {
|
||||||
|
return res.sessions.map(s => ({
|
||||||
|
token: s.token || '',
|
||||||
|
deviceName: s.deviceName || s.device_name || 'Браузер / Компьютер',
|
||||||
|
ipAddress: s.ipAddress || s.ip_address || '127.0.0.1',
|
||||||
|
createdAt: s.createdAt || s.created_at || '',
|
||||||
|
lastActive: s.lastActive || s.last_active || s.createdAt || '',
|
||||||
|
isCurrent: Boolean(s.isCurrent || s.is_current || (token && s.token === token))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fetch sessions error:', e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async terminateSession(sessionToken) {
|
||||||
|
if (!sessionToken) return { success: false, message: 'Не указан токен сессии' };
|
||||||
|
const res = await apiPost('sessions/terminate', { sessionToken, token: sessionToken });
|
||||||
|
return res || { success: false, message: 'Ошибка сети' };
|
||||||
|
},
|
||||||
|
|
||||||
|
async terminateOtherSessions() {
|
||||||
|
const res = await apiPost('sessions/terminate', { allOthers: true, all_others: true });
|
||||||
|
return res || { success: false, message: 'Ошибка сети' };
|
||||||
|
},
|
||||||
|
|
||||||
getUserData() {
|
getUserData() {
|
||||||
if (_cachedData) return _cachedData;
|
if (_cachedData) return _cachedData;
|
||||||
|
|
||||||
const local = localLoad();
|
const local = localLoad();
|
||||||
if (local) {
|
if (local) {
|
||||||
_cachedData = mergeUserData(local);
|
_cachedData = mergeUserData(local);
|
||||||
return _cachedData;
|
} else {
|
||||||
|
_cachedData = getDefaultData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background refresh from server if logged in
|
||||||
|
if (!this.isGuest() && getToken()) {
|
||||||
|
this.fetchProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedData = getDefaultData();
|
|
||||||
return _cachedData;
|
return _cachedData;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -362,6 +572,7 @@ window.PvZAuth = (function () {
|
|||||||
/** Force immediate server save (call on game-over / victory / visibility hidden / unload) */
|
/** Force immediate server save (call on game-over / victory / visibility hidden / unload) */
|
||||||
flushSave(keepalive = false) {
|
flushSave(keepalive = false) {
|
||||||
if (this.isGuest() || !_cachedData) return;
|
if (this.isGuest() || !_cachedData) return;
|
||||||
|
if (!_hasSyncedFromServer && getToken()) return; // Don't flush stale unsynced local cache over server
|
||||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||||
apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive);
|
apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive);
|
||||||
},
|
},
|
||||||
@@ -527,6 +738,13 @@ window.PvZAuth = (function () {
|
|||||||
}
|
}
|
||||||
authInstance.flushSave(true);
|
authInstance.flushSave(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-sync user data on startup if logged in
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!authInstance.isGuest() && authInstance.getToken()) {
|
||||||
|
authInstance.syncFromServer();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
return authInstance;
|
return authInstance;
|
||||||
|
|||||||
@@ -73,30 +73,101 @@ window.CampaignSystem = (function() {
|
|||||||
{ level: 25, world: 5, stage: 5, code: '5-5', title: 'Доктор Зомбосс!', desc: 'Финальная битва против шагающего робота Zombot!', waves: 1, biome: 'roof', isBoss: true, reward: 1000 }
|
{ level: 25, world: 5, stage: 5, code: '5-5', title: 'Доктор Зомбосс!', desc: 'Финальная битва против шагающего робота Zombot!', waves: 1, biome: 'roof', isBoss: true, reward: 1000 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let _activeLevels = [...LEVELS];
|
||||||
|
let _serverLoaded = false;
|
||||||
|
|
||||||
|
async function loadLevelsFromServer() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/campaign/levels');
|
||||||
|
if (!resp.ok) return _activeLevels;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data && data.success && Array.isArray(data.levels) && data.levels.length > 0) {
|
||||||
|
_activeLevels = data.levels.map(lvl => {
|
||||||
|
const num = parseInt(lvl.level, 10) || 1;
|
||||||
|
const defaultLvl = LEVELS.find(d => d.level === num) || {};
|
||||||
|
|
||||||
|
let wavesCount = 4;
|
||||||
|
if (lvl.waves && typeof lvl.waves === 'object' && Array.isArray(lvl.waves.list)) {
|
||||||
|
wavesCount = lvl.waves.list.length;
|
||||||
|
} else if (Array.isArray(lvl.waves)) {
|
||||||
|
wavesCount = lvl.waves.length;
|
||||||
|
} else if (typeof lvl.waves === 'number') {
|
||||||
|
wavesCount = lvl.waves;
|
||||||
|
} else if (defaultLvl.waves) {
|
||||||
|
wavesCount = defaultLvl.waves;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers = (lvl.modifiers && typeof lvl.modifiers === 'object') ? lvl.modifiers : {};
|
||||||
|
const wavesConfig = lvl.wavesConfig || lvl.waves || defaultLvl.wavesConfig || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultLvl,
|
||||||
|
...lvl,
|
||||||
|
waves: wavesCount,
|
||||||
|
modifiers: {
|
||||||
|
speedMultiplier: modifiers.speedMultiplier || lvl.speedMultiplier || 1.0,
|
||||||
|
hpMultiplier: modifiers.hpMultiplier || lvl.hpMultiplier || 1.0,
|
||||||
|
startingSun: modifiers.startingSun !== undefined ? modifiers.startingSun : (lvl.startingSun !== undefined ? lvl.startingSun : null)
|
||||||
|
},
|
||||||
|
wavesConfig,
|
||||||
|
isBoss: Boolean(lvl.isBoss || lvl.is_boss || num === 25 || defaultLvl.isBoss)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
_serverLoaded = true;
|
||||||
|
return _activeLevels;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Campaign server load error, using offline standard levels:', e);
|
||||||
|
}
|
||||||
|
return _activeLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load levels on initialization
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setTimeout(() => {
|
||||||
|
loadLevelsFromServer();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
WORLDS,
|
WORLDS,
|
||||||
LEVELS,
|
LEVELS,
|
||||||
DAVE_DIALOGUES,
|
DAVE_DIALOGUES,
|
||||||
|
loadLevelsFromServer,
|
||||||
|
isServerLoaded() {
|
||||||
|
return _serverLoaded;
|
||||||
|
},
|
||||||
|
getAllLevels() {
|
||||||
|
return _activeLevels;
|
||||||
|
},
|
||||||
getDaveDialogue(levelNum, mode = 'adventure') {
|
getDaveDialogue(levelNum, mode = 'adventure') {
|
||||||
if (mode === 'boss' || mode === 'bossrush') {
|
if (mode === 'boss' || mode === 'bossrush') {
|
||||||
return DAVE_DIALOGUES['5-5'];
|
return DAVE_DIALOGUES['5-5'];
|
||||||
}
|
}
|
||||||
const num = parseInt(levelNum, 10) || 1;
|
const num = parseInt(levelNum, 10) || 1;
|
||||||
const levelObj = LEVELS.find(l => l.level === num);
|
const levelObj = this.getLevel(num);
|
||||||
if (levelObj && DAVE_DIALOGUES[levelObj.code]) {
|
if (levelObj) {
|
||||||
return DAVE_DIALOGUES[levelObj.code];
|
if (levelObj.dialogue && typeof levelObj.dialogue === 'object' && levelObj.dialogue.text) {
|
||||||
|
return levelObj.dialogue;
|
||||||
|
}
|
||||||
|
if (levelObj.daveDialogue && typeof levelObj.daveDialogue === 'object' && levelObj.daveDialogue.text) {
|
||||||
|
return levelObj.daveDialogue;
|
||||||
|
}
|
||||||
|
if (DAVE_DIALOGUES[levelObj.code]) {
|
||||||
|
return DAVE_DIALOGUES[levelObj.code];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
getLevel(levelNum) {
|
getLevel(levelNum) {
|
||||||
const num = parseInt(levelNum, 10) || 1;
|
const num = parseInt(levelNum, 10) || 1;
|
||||||
return LEVELS.find(l => l.level === num) || LEVELS[0];
|
return _activeLevels.find(l => l.level === num) || LEVELS.find(l => l.level === num) || LEVELS[0];
|
||||||
},
|
},
|
||||||
getWorld(worldId) {
|
getWorld(worldId) {
|
||||||
return WORLDS.find(w => w.id === worldId) || WORLDS[0];
|
return WORLDS.find(w => w.id === worldId) || WORLDS[0];
|
||||||
},
|
},
|
||||||
getLevelsByWorld(worldId) {
|
getLevelsByWorld(worldId) {
|
||||||
return LEVELS.filter(l => l.world === worldId);
|
return _activeLevels.filter(l => l.world === worldId);
|
||||||
},
|
},
|
||||||
formatLevel(levelNum) {
|
formatLevel(levelNum) {
|
||||||
const item = this.getLevel(levelNum);
|
const item = this.getLevel(levelNum);
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// Crazy Dave's Casino & Bank System (Slot Machine & Bank Deposits/Loans)
|
||||||
|
window.CasinoSystem = class CasinoSystem {
|
||||||
|
constructor(economy) {
|
||||||
|
this.economy = economy;
|
||||||
|
|
||||||
|
// Slot machine symbols
|
||||||
|
this.symbols = [
|
||||||
|
{ id: 'sunflower', icon: '🌻', name: 'Подсолнух', weight: 30, mult: 4 },
|
||||||
|
{ id: 'pea', icon: '🟢', name: 'Горох', weight: 25, mult: 8 },
|
||||||
|
{ id: 'wallnut', icon: '🌰', name: 'Орех', weight: 20, mult: 12 },
|
||||||
|
{ id: 'cherry', icon: '🍒', name: 'Вишня', weight: 12, mult: 25 },
|
||||||
|
{ id: 'diamond', icon: '💎', name: 'Алмаз', weight: 6, mult: 60, crystals: 2 },
|
||||||
|
{ id: 'jackpot7', icon: '7️⃣', name: 'Семёрка', weight: 3, mult: 200, crystals: 5 },
|
||||||
|
{ id: 'brains', icon: '🧠', name: 'Мозги', weight: 4, mult: 0, isZombie: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Bank State
|
||||||
|
this.deposit = 0;
|
||||||
|
this.depositStartTime = 0;
|
||||||
|
this.interestRatePerHour = 0.05; // 5% per hour (up to 48 hours max)
|
||||||
|
this.loan = 0;
|
||||||
|
this.loanLimit = 2500;
|
||||||
|
this.loanInterest = 0.10; // 10% fee
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
this.spinsTotal = 0;
|
||||||
|
this.totalWon = 0;
|
||||||
|
this.jackpotsWon = 0;
|
||||||
|
|
||||||
|
this.isSpinning = false;
|
||||||
|
this.currentBet = 50;
|
||||||
|
this.lastReels = ['🌻', '🟢', '🌰'];
|
||||||
|
|
||||||
|
this.loadFromAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFromAuth() {
|
||||||
|
if (window.PvZAuth) {
|
||||||
|
const uData = window.PvZAuth.getUserData();
|
||||||
|
if (uData.casino) {
|
||||||
|
const c = uData.casino;
|
||||||
|
this.deposit = Number(c.deposit) || 0;
|
||||||
|
this.depositStartTime = Number(c.depositStartTime) || 0;
|
||||||
|
this.loan = Number(c.loan) || 0;
|
||||||
|
this.spinsTotal = Number(c.spinsTotal) || 0;
|
||||||
|
this.totalWon = Number(c.totalWon) || 0;
|
||||||
|
this.jackpotsWon = Number(c.jackpotsWon) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToAuth() {
|
||||||
|
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||||
|
window.PvZAuth.saveUserData({
|
||||||
|
casino: {
|
||||||
|
deposit: this.deposit,
|
||||||
|
depositStartTime: this.depositStartTime,
|
||||||
|
loan: this.loan,
|
||||||
|
spinsTotal: this.spinsTotal,
|
||||||
|
totalWon: this.totalWon,
|
||||||
|
jackpotsWon: this.jackpotsWon
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get random symbol based on weights
|
||||||
|
getRandomSymbol() {
|
||||||
|
const totalWeight = this.symbols.reduce((sum, s) => sum + s.weight, 0);
|
||||||
|
let rand = Math.random() * totalWeight;
|
||||||
|
for (const s of this.symbols) {
|
||||||
|
if (rand < s.weight) return s;
|
||||||
|
rand -= s.weight;
|
||||||
|
}
|
||||||
|
return this.symbols[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spin the reels
|
||||||
|
spin(bet = this.currentBet) {
|
||||||
|
if (this.isSpinning) return { error: 'Уже крутится!' };
|
||||||
|
if (!this.economy || this.economy.totalCoins < bet) {
|
||||||
|
return { error: 'Недостаточно монет для ставки!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct bet
|
||||||
|
this.economy.totalCoins -= bet;
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
this.spinsTotal++;
|
||||||
|
this.isSpinning = true;
|
||||||
|
|
||||||
|
// Pick 3 symbols
|
||||||
|
const s1 = this.getRandomSymbol();
|
||||||
|
const s2 = this.getRandomSymbol();
|
||||||
|
const s3 = this.getRandomSymbol();
|
||||||
|
this.lastReels = [s1.icon, s2.icon, s3.icon];
|
||||||
|
|
||||||
|
let payout = 0;
|
||||||
|
let crystalsAwarded = 0;
|
||||||
|
let message = 'Повезет в следующий раз!';
|
||||||
|
let isJackpot = false;
|
||||||
|
|
||||||
|
// Check matches
|
||||||
|
if (s1.id === s2.id && s2.id === s3.id) {
|
||||||
|
// 3 of a kind
|
||||||
|
if (s1.id === 'jackpot7') {
|
||||||
|
payout = bet * s1.mult;
|
||||||
|
crystalsAwarded = s1.crystals || 5;
|
||||||
|
isJackpot = true;
|
||||||
|
this.jackpotsWon++;
|
||||||
|
message = `🔥 МЕГА ДЖЕКПОТ! +${payout} монет и +${crystalsAwarded} алмазов!`;
|
||||||
|
} else if (s1.id === 'brains') {
|
||||||
|
payout = 0;
|
||||||
|
message = '🧟 АТАКА ЗОМБИ! Все барабаны съедены!';
|
||||||
|
} else {
|
||||||
|
payout = bet * s1.mult;
|
||||||
|
if (s1.crystals) crystalsAwarded = s1.crystals;
|
||||||
|
message = `🎉 СУПЕР ВЫИГРЫШ: 3x ${s1.name}! +${payout} монет!`;
|
||||||
|
}
|
||||||
|
} else if (s1.id === s2.id || s2.id === s3.id || s1.id === s3.id) {
|
||||||
|
// 2 of a kind
|
||||||
|
const matched = (s1.id === s2.id) ? s1 : (s2.id === s3.id ? s2 : s1);
|
||||||
|
if (!matched.isZombie) {
|
||||||
|
payout = Math.round(bet * Math.max(1.5, matched.mult * 0.35));
|
||||||
|
message = `✨ Выигрыш: пара ${matched.icon}! +${payout} монет`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payout > 0) {
|
||||||
|
this.economy.totalCoins += payout;
|
||||||
|
this.totalWon += payout;
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
if (crystalsAwarded > 0 && this.economy.engine && this.economy.engine.workshop) {
|
||||||
|
this.economy.engine.workshop.addMaterial('crystals', crystalsAwarded);
|
||||||
|
}
|
||||||
|
if (window.PvZAudio) {
|
||||||
|
if (isJackpot) window.PvZAudio.playWin();
|
||||||
|
else window.PvZAudio.playCoin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
reels: [s1, s2, s3],
|
||||||
|
icons: [s1.icon, s2.icon, s3.icon],
|
||||||
|
payout,
|
||||||
|
crystals: crystalsAwarded,
|
||||||
|
isJackpot,
|
||||||
|
message,
|
||||||
|
coinsLeft: this.economy.totalCoins
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate accrued interest on deposit
|
||||||
|
getAccruedInterest() {
|
||||||
|
if (this.deposit <= 0 || !this.depositStartTime) return 0;
|
||||||
|
const elapsedMs = Date.now() - this.depositStartTime;
|
||||||
|
const hours = Math.min(elapsedMs / (1000 * 3600), 48); // max 48h cap
|
||||||
|
return Math.floor(this.deposit * (this.interestRatePerHour * hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit coins into bank
|
||||||
|
depositCoins(amount) {
|
||||||
|
amount = Math.floor(amount);
|
||||||
|
if (amount <= 0) return { success: false, message: 'Некорректная сумма' };
|
||||||
|
if (!this.economy || this.economy.totalCoins < amount) {
|
||||||
|
return { success: false, message: 'Недостаточно монет на руках' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect any prior accrued interest
|
||||||
|
const interest = this.getAccruedInterest();
|
||||||
|
const priorDeposit = this.deposit;
|
||||||
|
|
||||||
|
this.economy.totalCoins -= amount;
|
||||||
|
this.deposit = priorDeposit + interest + amount;
|
||||||
|
this.depositStartTime = Date.now();
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
this.saveToAuth();
|
||||||
|
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deposit: this.deposit,
|
||||||
|
addedInterest: interest,
|
||||||
|
coinsLeft: this.economy.totalCoins,
|
||||||
|
message: `Успешно внесено ${amount} монет! (Начислено %: +${interest})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdraw full deposit + interest
|
||||||
|
withdrawDeposit() {
|
||||||
|
if (this.deposit <= 0) return { success: false, message: 'В банке нет вклада' };
|
||||||
|
|
||||||
|
const interest = this.getAccruedInterest();
|
||||||
|
const totalPayout = this.deposit + interest;
|
||||||
|
|
||||||
|
this.economy.totalCoins += totalPayout;
|
||||||
|
this.deposit = 0;
|
||||||
|
this.depositStartTime = 0;
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
this.saveToAuth();
|
||||||
|
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
payout: totalPayout,
|
||||||
|
interest,
|
||||||
|
coinsLeft: this.economy.totalCoins,
|
||||||
|
message: `Снято из банка: ${totalPayout} монет (включая % доход: +${interest})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take a loan from Crazy Dave
|
||||||
|
takeLoan(amount) {
|
||||||
|
amount = Math.floor(amount);
|
||||||
|
if (this.loan > 0) {
|
||||||
|
return { success: false, message: `У вас уже есть непогашенный кредит: ${this.loan} монет!` };
|
||||||
|
}
|
||||||
|
if (amount <= 0 || amount > this.loanLimit) {
|
||||||
|
return { success: false, message: `Лимит кредита: от 100 до ${this.loanLimit} монет` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDebt = Math.round(amount * (1 + this.loanInterest));
|
||||||
|
this.loan = totalDebt;
|
||||||
|
this.economy.totalCoins += amount;
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
this.saveToAuth();
|
||||||
|
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
amount,
|
||||||
|
debt: totalDebt,
|
||||||
|
coinsLeft: this.economy.totalCoins,
|
||||||
|
message: `Кредит выдан! Получено +${amount} монет. К возврату: ${totalDebt} монет.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repay part or all loan
|
||||||
|
repayLoan(amount) {
|
||||||
|
if (this.loan <= 0) return { success: false, message: 'У вас нет долгов!' };
|
||||||
|
amount = Math.min(this.loan, Math.floor(amount));
|
||||||
|
if (amount <= 0 || this.economy.totalCoins < amount) {
|
||||||
|
return { success: false, message: 'Недостаточно монет для погашения' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.economy.totalCoins -= amount;
|
||||||
|
this.loan -= amount;
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
this.saveToAuth();
|
||||||
|
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
paid: amount,
|
||||||
|
debtLeft: this.loan,
|
||||||
|
coinsLeft: this.economy.totalCoins,
|
||||||
|
message: this.loan === 0 ? '🎉 КРЕДИТ ПОЛНОСТЬЮ ПОГАШЕН!' : `Погашено ${amount} монет. Остаток долга: ${this.loan}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -516,6 +516,11 @@ window.CombatSystem = class CombatSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In I, Zombie mode, zombies are spawned by the player, so defeating them should not drop coins/loot
|
||||||
|
if (gameState.currentMode === 'izombie') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const perkBonus = gameState.workshop ? gameState.workshop.getPerkLevel('luckyDrop') * 0.35 : 0;
|
const perkBonus = gameState.workshop ? gameState.workshop.getPerkLevel('luckyDrop') * 0.35 : 0;
|
||||||
|
|
||||||
// Coins Drop
|
// Coins Drop
|
||||||
|
|||||||
+17
-10
@@ -21,7 +21,7 @@ window.PLANT_CATALOGUE = [
|
|||||||
// Economy, Sun, Coin Drops & Crazy Dave's Shop System
|
// Economy, Sun, Coin Drops & Crazy Dave's Shop System
|
||||||
window.EconomySystem = class EconomySystem {
|
window.EconomySystem = class EconomySystem {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sun = 50;
|
this.sun = window.PvZConfig ? window.PvZConfig.getStartingSun() : 50;
|
||||||
this.suns = [];
|
this.suns = [];
|
||||||
this.coins = [];
|
this.coins = [];
|
||||||
this.skySunTimer = 0;
|
this.skySunTimer = 0;
|
||||||
@@ -42,7 +42,7 @@ window.EconomySystem = class EconomySystem {
|
|||||||
this.allCards = window.PLANT_CATALOGUE.map(p => ({
|
this.allCards = window.PLANT_CATALOGUE.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
cost: p.cost,
|
cost: window.PvZConfig ? window.PvZConfig.getPlantSunCost(p.id, p.cost) : p.cost,
|
||||||
cooldown: p.cooldown,
|
cooldown: p.cooldown,
|
||||||
timer: 0
|
timer: 0
|
||||||
}));
|
}));
|
||||||
@@ -69,7 +69,7 @@ window.EconomySystem = class EconomySystem {
|
|||||||
if (!p) return { success: false, message: 'Растение не найдено' };
|
if (!p) return { success: false, message: 'Растение не найдено' };
|
||||||
if (this.isPlantUnlocked(plantId)) return { success: false, message: 'Растение уже куплено' };
|
if (this.isPlantUnlocked(plantId)) return { success: false, message: 'Растение уже куплено' };
|
||||||
|
|
||||||
const price = p.price || 0;
|
const price = window.PvZConfig ? window.PvZConfig.getPlantShopPrice(plantId) : (p.price || 0);
|
||||||
if (this.totalCoins < price) {
|
if (this.totalCoins < price) {
|
||||||
return { success: false, message: `Недостаточно монет! Требуется ${price} 💰 (у вас: ${this.totalCoins} 💰)` };
|
return { success: false, message: `Недостаточно монет! Требуется ${price} 💰 (у вас: ${this.totalCoins} 💰)` };
|
||||||
}
|
}
|
||||||
@@ -270,7 +270,7 @@ window.EconomySystem = class EconomySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
let baseSun = this.isConveyor ? 0 : (this.isIZombie ? 200 : 50);
|
let baseSun = this.isConveyor ? 0 : (this.isIZombie ? 200 : (window.PvZConfig ? window.PvZConfig.getStartingSun() : 50));
|
||||||
if (!this.isConveyor && !this.isIZombie && this.workshop) {
|
if (!this.isConveyor && !this.isIZombie && this.workshop) {
|
||||||
baseSun += this.workshop.getPerkLevel('startSun') * 50;
|
baseSun += this.workshop.getPerkLevel('startSun') * 50;
|
||||||
}
|
}
|
||||||
@@ -330,11 +330,13 @@ window.EconomySystem = class EconomySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawnSun(x, y, value = 25, isFalling = false, targetY = null) {
|
spawnSun(x, y, value = 25, isFalling = false, targetY = null) {
|
||||||
|
const sunMult = window.PvZConfig ? window.PvZConfig.getSunMultiplier() : 1.0;
|
||||||
|
const finalVal = Math.max(5, Math.round(value * sunMult));
|
||||||
this.suns.push({
|
this.suns.push({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
targetY: targetY !== null ? targetY : y,
|
targetY: targetY !== null ? targetY : y,
|
||||||
value,
|
value: finalVal,
|
||||||
isFalling,
|
isFalling,
|
||||||
fallSpeed: 90,
|
fallSpeed: 90,
|
||||||
radius: 26,
|
radius: 26,
|
||||||
@@ -346,12 +348,14 @@ window.EconomySystem = class EconomySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawnCoin(x, y, type = 'silver') {
|
spawnCoin(x, y, type = 'silver') {
|
||||||
const val = type === 'diamond' ? 200 : (type === 'gold' ? 50 : 10);
|
const dropMult = window.PvZConfig ? window.PvZConfig.getCoinDropRate() : 1.0;
|
||||||
|
const baseVal = type === 'diamond' ? 200 : (type === 'gold' ? 50 : 10);
|
||||||
|
const finalVal = Math.max(1, Math.round(baseVal * dropMult));
|
||||||
this.coins.push({
|
this.coins.push({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
type,
|
type,
|
||||||
value: val,
|
value: finalVal,
|
||||||
radius: 16,
|
radius: 16,
|
||||||
life: 12.0,
|
life: 12.0,
|
||||||
collecting: false,
|
collecting: false,
|
||||||
@@ -363,9 +367,10 @@ window.EconomySystem = class EconomySystem {
|
|||||||
update(dt) {
|
update(dt) {
|
||||||
this.animTime += dt;
|
this.animTime += dt;
|
||||||
|
|
||||||
// Cooldown timers with fastCool perk bonus
|
// Cooldown timers with fastCool perk bonus and PvZConfig
|
||||||
const fastCoolLvl = this.workshop ? this.workshop.getPerkLevel('fastCool') : 0;
|
const fastCoolLvl = this.workshop ? this.workshop.getPerkLevel('fastCool') : 0;
|
||||||
const cooldownMultiplier = 1 + fastCoolLvl * 0.15;
|
const cfgCooldownMult = window.PvZConfig ? (1 / Math.max(0.1, window.PvZConfig.getPlantCooldownMult())) : 1.0;
|
||||||
|
const cooldownMultiplier = (1 + fastCoolLvl * 0.15) * cfgCooldownMult;
|
||||||
for (const card of this.cards) {
|
for (const card of this.cards) {
|
||||||
if (card.timer > 0) {
|
if (card.timer > 0) {
|
||||||
card.timer = Math.max(0, card.timer - dt * cooldownMultiplier);
|
card.timer = Math.max(0, card.timer - dt * cooldownMultiplier);
|
||||||
@@ -375,7 +380,9 @@ window.EconomySystem = class EconomySystem {
|
|||||||
// Sky sun spawner (disabled at night and in conveyor mode)
|
// Sky sun spawner (disabled at night and in conveyor mode)
|
||||||
if (!this.isNight && !this.isConveyor) {
|
if (!this.isNight && !this.isConveyor) {
|
||||||
this.skySunTimer += dt;
|
this.skySunTimer += dt;
|
||||||
if (this.skySunTimer >= this.skySunInterval) {
|
const sunDropMult = window.PvZConfig ? window.PvZConfig.getSunDropRate() : 1.0;
|
||||||
|
const effectiveInterval = this.skySunInterval / Math.max(0.2, sunDropMult);
|
||||||
|
if (this.skySunTimer >= effectiveInterval) {
|
||||||
this.skySunTimer = 0;
|
this.skySunTimer = 0;
|
||||||
const spawnX = 140 + Math.random() * 640;
|
const spawnX = 140 + Math.random() * 640;
|
||||||
const targetY = 140 + Math.random() * 380;
|
const targetY = 140 + Math.random() * 380;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ window.LeaderboardSystem = class LeaderboardSystem {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.loadFromAuth();
|
this.loadFromAuth();
|
||||||
|
this.fetchAllLeaderboards();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFromAuth() {
|
loadFromAuth() {
|
||||||
@@ -59,15 +60,77 @@ window.LeaderboardSystem = class LeaderboardSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchAllLeaderboards() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/leaderboard');
|
||||||
|
if (resp.ok) {
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json && json.success && json.records) {
|
||||||
|
for (const [mode, rows] of Object.entries(json.records)) {
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
this.mergeServerRecords(mode, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (window._pvzEngine && typeof window._pvzEngine.renderLeaderboardUI === 'function') {
|
||||||
|
window._pvzEngine.renderLeaderboardUI(this.selectedMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLeaderboard(mode) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/leaderboard?mode=${encodeURIComponent(mode)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json && json.success && Array.isArray(json.records)) {
|
||||||
|
this.mergeServerRecords(mode, json.records);
|
||||||
|
if (window._pvzEngine && typeof window._pvzEngine.renderLeaderboardUI === 'function') {
|
||||||
|
window._pvzEngine.renderLeaderboardUI(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeServerRecords(mode, serverRows) {
|
||||||
|
if (!this.records[mode]) {
|
||||||
|
this.records[mode] = [];
|
||||||
|
}
|
||||||
|
const combined = [...this.records[mode]];
|
||||||
|
for (const sr of serverRows) {
|
||||||
|
const existing = combined.find(r => r.name === sr.name);
|
||||||
|
if (existing) {
|
||||||
|
if (sr.score > existing.score) {
|
||||||
|
existing.score = sr.score;
|
||||||
|
existing.detail = sr.detail;
|
||||||
|
existing.date = sr.date;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
combined.push({
|
||||||
|
name: sr.name,
|
||||||
|
score: sr.score,
|
||||||
|
detail: sr.detail,
|
||||||
|
date: sr.date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
combined.sort((a, b) => b.score - a.score);
|
||||||
|
this.records[mode] = combined.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
submitScore(mode, score, detail = '') {
|
submitScore(mode, score, detail = '') {
|
||||||
if (!this.records[mode]) {
|
if (!this.records[mode]) {
|
||||||
this.records[mode] = [];
|
this.records[mode] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let playerName = 'Игрок';
|
let playerName = 'Игрок';
|
||||||
|
let token = null;
|
||||||
if (window.PvZAuth) {
|
if (window.PvZAuth) {
|
||||||
playerName = window.PvZAuth.getCurrentUser();
|
playerName = window.PvZAuth.getCurrentUser();
|
||||||
if (window.PvZAuth.isGuest()) playerName = 'Гость';
|
if (window.PvZAuth.isGuest()) playerName = 'Гость';
|
||||||
|
token = window.PvZAuth.getToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
@@ -94,6 +157,23 @@ window.LeaderboardSystem = class LeaderboardSystem {
|
|||||||
// Keep top 10
|
// Keep top 10
|
||||||
this.records[mode] = this.records[mode].slice(0, 10);
|
this.records[mode] = this.records[mode].slice(0, 10);
|
||||||
this.saveToAuth();
|
this.saveToAuth();
|
||||||
|
|
||||||
|
// Submit to server leaderboard asynchronously
|
||||||
|
try {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
fetch('/api/leaderboard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode,
|
||||||
|
score,
|
||||||
|
detail: detail || `${score} очков`,
|
||||||
|
name: playerName,
|
||||||
|
token
|
||||||
|
})
|
||||||
|
}).then(r => r.json()).catch(() => {});
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
getEntries(mode) {
|
getEntries(mode) {
|
||||||
|
|||||||
+12
-2
@@ -12,7 +12,8 @@ window.TreeSystem = class TreeSystem {
|
|||||||
future: false,
|
future: false,
|
||||||
daisies: false,
|
daisies: false,
|
||||||
dance: false,
|
dance: false,
|
||||||
pinata: false
|
pinata: false,
|
||||||
|
shrek: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wisdom unlocked at various heights
|
// Wisdom unlocked at various heights
|
||||||
@@ -50,6 +51,12 @@ window.TreeSystem = class TreeSystem {
|
|||||||
text: '«Смерть нежити может приносить радость. Введи код daisies — при падении зомби на земле будут расцветать прекрасные маргаритки!»',
|
text: '«Смерть нежити может приносить радость. Введи код daisies — при падении зомби на земле будут расцветать прекрасные маргаритки!»',
|
||||||
cheat: 'daisies'
|
cheat: 'daisies'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
height: 60,
|
||||||
|
title: 'Секретный код: SHREK',
|
||||||
|
text: '«Легенда болот гласит: введи shrek во время боя, и гигантский зелёный великан сокрушит орду зомби землетрясением!»',
|
||||||
|
cheat: 'shrek'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
height: 75,
|
height: 75,
|
||||||
title: 'Секрет Доктора Зомбосса',
|
title: 'Секрет Доктора Зомбосса',
|
||||||
@@ -124,10 +131,13 @@ window.TreeSystem = class TreeSystem {
|
|||||||
buffer += char;
|
buffer += char;
|
||||||
if (buffer.length > 15) buffer = buffer.slice(-15);
|
if (buffer.length > 15) buffer = buffer.slice(-15);
|
||||||
|
|
||||||
for (const code of ['mustache', 'future', 'daisies', 'dance', 'pinata']) {
|
for (const code of ['mustache', 'future', 'daisies', 'dance', 'pinata', 'shrek']) {
|
||||||
if (buffer.endsWith(code)) {
|
if (buffer.endsWith(code)) {
|
||||||
const state = this.toggleCheat(code);
|
const state = this.toggleCheat(code);
|
||||||
this.showCheatToast(code, state);
|
this.showCheatToast(code, state);
|
||||||
|
if (code === 'shrek' && window._pvzEngine && window._pvzEngine.isRunning) {
|
||||||
|
window._pvzEngine.triggerShrekEasterEgg();
|
||||||
|
}
|
||||||
buffer = '';
|
buffer = '';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-1
@@ -49,7 +49,17 @@ window.WaveSystem = class WaveSystem {
|
|||||||
if (campLvl.isBoss) {
|
if (campLvl.isBoss) {
|
||||||
this.mode = 'boss';
|
this.mode = 'boss';
|
||||||
}
|
}
|
||||||
|
this.customWavesConfig = campLvl.wavesConfig || campLvl.waves || null;
|
||||||
|
this.modifiers = campLvl.modifiers || {};
|
||||||
|
this.speedMultiplier = this.modifiers.speedMultiplier || campLvl.speedMultiplier || 1.0;
|
||||||
|
this.hpMultiplier = this.modifiers.hpMultiplier || campLvl.hpMultiplier || 1.0;
|
||||||
|
this.startingSun = this.modifiers.startingSun !== undefined ? this.modifiers.startingSun : (campLvl.startingSun !== undefined ? campLvl.startingSun : null);
|
||||||
} else {
|
} else {
|
||||||
|
this.customWavesConfig = null;
|
||||||
|
this.modifiers = {};
|
||||||
|
this.speedMultiplier = 1.0;
|
||||||
|
this.hpMultiplier = 1.0;
|
||||||
|
this.startingSun = null;
|
||||||
if (level <= 5) {
|
if (level <= 5) {
|
||||||
this.biome = 'day';
|
this.biome = 'day';
|
||||||
this.totalWaves = (level === 1) ? 3 : 4;
|
this.totalWaves = (level === 1) ? 3 : 4;
|
||||||
@@ -209,7 +219,74 @@ window.WaveSystem = class WaveSystem {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adventure Campaign Waves
|
// Adventure Campaign Waves (Custom Server Waves or Standard Biome Progression)
|
||||||
|
if (this.customWavesConfig) {
|
||||||
|
let customWave = null;
|
||||||
|
if (Array.isArray(this.customWavesConfig)) {
|
||||||
|
customWave = this.customWavesConfig.find(w => w && w.wave === this.currentWave) || this.customWavesConfig[this.currentWave - 1];
|
||||||
|
} else if (typeof this.customWavesConfig === 'object') {
|
||||||
|
if (Array.isArray(this.customWavesConfig.list)) {
|
||||||
|
customWave = this.customWavesConfig.list.find(w => w && w.wave === this.currentWave) || this.customWavesConfig.list[this.currentWave - 1];
|
||||||
|
} else {
|
||||||
|
customWave = this.customWavesConfig[String(this.currentWave)] || this.customWavesConfig[this.currentWave - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customWave && (customWave.squad || customWave.zombies)) {
|
||||||
|
const isLastWave = this.currentWave >= this.totalWaves;
|
||||||
|
const defaultBanner = isLastWave ? '☠️ ПОСЛЕДНЯЯ ВОЛНА!' : `ВОЛНА ${this.currentWave} / ${this.totalWaves}`;
|
||||||
|
this.bannerText = customWave.banner || customWave.bannerText || defaultBanner;
|
||||||
|
this.bannerTimer = customWave.bannerTimer || (isLastWave ? 4.0 : 3.0);
|
||||||
|
|
||||||
|
if (customWave.isHuge || customWave.is_huge || isLastWave || this.bannerText.includes('БОЛЬШАЯ') || this.bannerText.includes('ПОСЛЕДНЯЯ')) {
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.spawnQueue = [];
|
||||||
|
const rawSquad = customWave.squad || customWave.zombies || [];
|
||||||
|
if (Array.isArray(rawSquad)) {
|
||||||
|
for (let i = 0; i < rawSquad.length; i++) {
|
||||||
|
const item = rawSquad[i];
|
||||||
|
const zType = typeof item === 'string' ? item : (item.type || 'normal');
|
||||||
|
const delay = (typeof item === 'object' && item.delay !== undefined) ? item.delay : (2.0 + i * 2.2);
|
||||||
|
let row = (typeof item === 'object' && item.row !== undefined && item.row >= 0) ? item.row : null;
|
||||||
|
if (row === null) {
|
||||||
|
row = (zType === 'duckytube' && (this.biome === 'pool' || this.biome === 'fog'))
|
||||||
|
? (Math.random() > 0.5 ? 2 : 3)
|
||||||
|
: this.randomRow(gameState);
|
||||||
|
}
|
||||||
|
this.spawnQueue.push({ delay, type: zType, row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Night mode gravestones rise on final or huge wave
|
||||||
|
if (this.biome === 'night' && (isLastWave || customWave.isHuge)) {
|
||||||
|
for (const grave of gameState.grid.graves) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gameState.isRunning) {
|
||||||
|
const bounds = gameState.grid.getCellBounds(grave.col, grave.row);
|
||||||
|
const z = new window.Zombie(grave.row, 'normal', bounds.centerX);
|
||||||
|
z.y = bounds.centerY - 5;
|
||||||
|
if (this.hpMultiplier && this.hpMultiplier !== 1.0) {
|
||||||
|
z.maxHp *= this.hpMultiplier;
|
||||||
|
z.hp *= this.hpMultiplier;
|
||||||
|
}
|
||||||
|
if (this.speedMultiplier && this.speedMultiplier !== 1.0) {
|
||||||
|
z.speed *= this.speedMultiplier;
|
||||||
|
}
|
||||||
|
gameState.zombies.push(z);
|
||||||
|
gameState.combat.createExplosionParticles(bounds.centerX, bounds.centerY);
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialWaveZombies = Math.max(1, this.spawnQueue.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Biome Standard Campaign Wave Progression
|
||||||
if (this.currentWave === 1) {
|
if (this.currentWave === 1) {
|
||||||
this.bannerText = 'ПЕРВЫЕ ЗОМБИ...';
|
this.bannerText = 'ПЕРВЫЕ ЗОМБИ...';
|
||||||
this.bannerTimer = 3.0;
|
this.bannerTimer = 3.0;
|
||||||
|
|||||||
@@ -205,12 +205,14 @@ window.ZenGardenSystem = class ZenGardenSystem {
|
|||||||
const baseX = 120 + potCol * 150;
|
const baseX = 120 + potCol * 150;
|
||||||
const baseY = 95 + potRow * 125;
|
const baseY = 95 + potRow * 125;
|
||||||
|
|
||||||
|
const dropMult = window.PvZConfig ? window.PvZConfig.getZenDropRateMult() : 1.0;
|
||||||
|
const baseVal = coinType === 'diamond' ? 200 : (coinType === 'gold' ? 50 : 10);
|
||||||
this.gardenCoins.push({
|
this.gardenCoins.push({
|
||||||
x: baseX + (Math.random() * 30 - 15),
|
x: baseX + (Math.random() * 30 - 15),
|
||||||
y: baseY + 30,
|
y: baseY + 30,
|
||||||
vy: -140,
|
vy: -140,
|
||||||
type: coinType,
|
type: coinType,
|
||||||
value: coinType === 'diamond' ? 200 : (coinType === 'gold' ? 50 : 10),
|
value: Math.max(1, Math.round(baseVal * dropMult)),
|
||||||
collected: false,
|
collected: false,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
anim: 0
|
anim: 0
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Zombie Lab & Mutation System for "I, Zombie" mode
|
||||||
|
window.ZombieLabSystem = class ZombieLabSystem {
|
||||||
|
constructor(economy, workshop) {
|
||||||
|
this.economy = economy;
|
||||||
|
this.workshop = workshop;
|
||||||
|
|
||||||
|
// Upgradable zombie definitions
|
||||||
|
this.zombies = {
|
||||||
|
normal: {
|
||||||
|
id: 'normal',
|
||||||
|
name: 'Обычный зомби',
|
||||||
|
icon: '🧟',
|
||||||
|
tiers: {
|
||||||
|
1: { title: 'Базовый ходок', desc: 'HP 200, стандартная скорость' },
|
||||||
|
2: { title: 'Спринтер', desc: 'Скорость бега +35%', cost: { coins: 150, scrapMetal: 8 } },
|
||||||
|
3: { title: 'Зомби-Вампир', desc: 'Лечит +45 HP за каждый укус растения', cost: { coins: 350, scrapMetal: 15, bioHumus: 10 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
conehead: {
|
||||||
|
id: 'conehead',
|
||||||
|
name: 'Зомби с конусом',
|
||||||
|
icon: '⚠️',
|
||||||
|
tiers: {
|
||||||
|
1: { title: 'Дорожный конус', desc: 'HP 560 (конус поглощает урон)' },
|
||||||
|
2: { title: 'Титановый конус', desc: 'HP 800 + устойчивость к критам', cost: { coins: 200, scrapMetal: 15 } },
|
||||||
|
3: { title: 'Шипованный шлем', desc: 'HP 1050 + отражает 25% урона стрелкам', cost: { coins: 450, scrapMetal: 30, crystals: 1 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
polevaulter: {
|
||||||
|
id: 'polevaulter',
|
||||||
|
name: 'Зомби с шестом',
|
||||||
|
icon: '🏃',
|
||||||
|
tiers: {
|
||||||
|
1: { title: 'Легкоатлет', desc: 'Быстрый разбег, 1 прыжок через растение' },
|
||||||
|
2: { title: 'Двойной шест', desc: 'Может перепрыгнуть 2 растения подряд!', cost: { coins: 250, scrapMetal: 20, bioHumus: 10 } },
|
||||||
|
3: { title: 'Турбо-спринтер', desc: 'Спринт +50% скорости даже после приземления', cost: { coins: 500, scrapMetal: 35, crystals: 1 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buckethead: {
|
||||||
|
id: 'buckethead',
|
||||||
|
name: 'Зомби с ведром',
|
||||||
|
icon: '🪣',
|
||||||
|
tiers: {
|
||||||
|
1: { title: 'Стальное ведро', desc: 'HP 1300 тяжелая броня' },
|
||||||
|
2: { title: 'Тяжелый сплав', desc: 'HP 1800 колоссальная прочность', cost: { coins: 300, scrapMetal: 25 } },
|
||||||
|
3: { title: 'Крио-изоляция', desc: 'HP 2300 + полный иммунитет к замедлению льдом!', cost: { coins: 650, scrapMetal: 45, crystals: 2 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
football: {
|
||||||
|
id: 'football',
|
||||||
|
name: 'Зомби-футболист',
|
||||||
|
icon: '🏈',
|
||||||
|
tiers: {
|
||||||
|
1: { title: 'Квотербек', desc: 'HP 1600 суперскорость и шлем' },
|
||||||
|
2: { title: 'Тяжелый лайнмен', desc: 'HP 2200 + поглощает 20% урона', cost: { coins: 400, scrapMetal: 35 } },
|
||||||
|
3: { title: 'Безумный таран', desc: 'HP 2800 + сминает картофельную мину без взрыва!', cost: { coins: 800, scrapMetal: 60, crystals: 2 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// User levels (1..3)
|
||||||
|
this.levels = {
|
||||||
|
normal: 1,
|
||||||
|
conehead: 1,
|
||||||
|
polevaulter: 1,
|
||||||
|
buckethead: 1,
|
||||||
|
football: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unlocked stage in I, Zombie campaign (1..5)
|
||||||
|
this.unlockedStage = 1;
|
||||||
|
this.selectedStage = 1;
|
||||||
|
|
||||||
|
this.stages = [
|
||||||
|
{ id: 1, title: '1. Зелёный Двор', desc: 'Базовый газон Дэйва. Съешьте все подсолнухи!', reward: 100 },
|
||||||
|
{ id: 2, title: '2. Ночной Дозор', desc: 'Тёмный двор с Дымогрибами и Могилами', reward: 150 },
|
||||||
|
{ id: 3, title: '3. Водный Штурм', desc: '6 дорожек, Кувшинки и тройные Горохострелы', reward: 220 },
|
||||||
|
{ id: 4, title: '4. Ореховый Бастион', desc: 'Мощный частокол Стеноорехов и Картофельных Мин', reward: 300 },
|
||||||
|
{ id: 5, title: '5. РЕЙД-БОСС: Золотой Подсолнух', desc: 'Эпический бой против гигантского Подсолнуха-Босса 5000 HP!', reward: 600, crystals: 2, isBoss: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.loadFromAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFromAuth() {
|
||||||
|
if (window.PvZAuth) {
|
||||||
|
const uData = window.PvZAuth.getUserData();
|
||||||
|
if (uData.zombieLab) {
|
||||||
|
const zl = uData.zombieLab;
|
||||||
|
if (zl.levels) {
|
||||||
|
for (const k in this.levels) {
|
||||||
|
if (zl.levels[k]) this.levels[k] = Math.min(3, Math.max(1, Number(zl.levels[k])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (zl.unlockedStage) {
|
||||||
|
this.unlockedStage = Math.max(1, Math.min(5, Number(zl.unlockedStage)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToAuth() {
|
||||||
|
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||||
|
window.PvZAuth.saveUserData({
|
||||||
|
zombieLab: {
|
||||||
|
levels: { ...this.levels },
|
||||||
|
unlockedStage: this.unlockedStage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getZombieLevel(type) {
|
||||||
|
return this.levels[type] || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
canUpgrade(type) {
|
||||||
|
const curLevel = this.getZombieLevel(type);
|
||||||
|
if (curLevel >= 3) return false;
|
||||||
|
const def = this.zombies[type];
|
||||||
|
if (!def) return false;
|
||||||
|
const nextTier = def.tiers[curLevel + 1];
|
||||||
|
if (!nextTier) return false;
|
||||||
|
|
||||||
|
const cost = nextTier.cost;
|
||||||
|
if (cost.coins && (!this.economy || this.economy.totalCoins < cost.coins)) return false;
|
||||||
|
if (this.workshop) {
|
||||||
|
if (cost.scrapMetal && (this.workshop.materials.scrapMetal || 0) < cost.scrapMetal) return false;
|
||||||
|
if (cost.bioHumus && (this.workshop.materials.bioHumus || 0) < cost.bioHumus) return false;
|
||||||
|
if (cost.crystals && (this.workshop.materials.crystals || 0) < cost.crystals) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
upgradeZombie(type) {
|
||||||
|
const curLevel = this.getZombieLevel(type);
|
||||||
|
if (curLevel >= 3) return { success: false, message: 'Максимальный уровень мутации!' };
|
||||||
|
const def = this.zombies[type];
|
||||||
|
if (!def) return { success: false, message: 'Неизвестный зомби' };
|
||||||
|
const nextTier = def.tiers[curLevel + 1];
|
||||||
|
if (!nextTier) return { success: false, message: 'Нет следующего уровня' };
|
||||||
|
|
||||||
|
const cost = nextTier.cost;
|
||||||
|
if (cost.coins && (!this.economy || this.economy.totalCoins < cost.coins)) {
|
||||||
|
return { success: false, message: 'Недостаточно монет' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.workshop) {
|
||||||
|
if (cost.scrapMetal && (this.workshop.materials.scrapMetal || 0) < cost.scrapMetal) {
|
||||||
|
return { success: false, message: 'Недостаточно металлолома ⚙️' };
|
||||||
|
}
|
||||||
|
if (cost.bioHumus && (this.workshop.materials.bioHumus || 0) < cost.bioHumus) {
|
||||||
|
return { success: false, message: 'Недостаточно био-гумуса 🧪' };
|
||||||
|
}
|
||||||
|
if (cost.crystals && (this.workshop.materials.crystals || 0) < cost.crystals) {
|
||||||
|
return { success: false, message: 'Недостаточно кристаллов 💎' };
|
||||||
|
}
|
||||||
|
// Deduct materials
|
||||||
|
if (cost.scrapMetal) this.workshop.materials.scrapMetal -= cost.scrapMetal;
|
||||||
|
if (cost.bioHumus) this.workshop.materials.bioHumus -= cost.bioHumus;
|
||||||
|
if (cost.crystals) this.workshop.materials.crystals -= cost.crystals;
|
||||||
|
this.workshop.saveToAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cost.coins) {
|
||||||
|
this.economy.totalCoins -= cost.coins;
|
||||||
|
this.economy.saveProfileData();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.levels[type] = curLevel + 1;
|
||||||
|
this.saveToAuth();
|
||||||
|
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
newLevel: this.levels[type],
|
||||||
|
message: `🎉 ${def.name} мутировал до Ур. ${this.levels[type]} («${nextTier.title}»)!`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockNextStage(completedStage) {
|
||||||
|
if (completedStage >= this.unlockedStage && this.unlockedStage < 5) {
|
||||||
|
this.unlockedStage = completedStage + 1;
|
||||||
|
this.saveToAuth();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in new issue
Block a user