feat(gameplay): add crazy dave casino and bank, and zombie mutation lab

This commit is contained in:
Antigravity committed 2026-09-22 09:25:08 +00:00
1 parent d8be8f4366
commit b8db5a123d
16 files changed
+2354 -124

No files matched your search

+307
View File
@@ -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
View File
@@ -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>
+178 -3
View File
@@ -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);
if (this.type === 'mega_sunflower') {
// 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! // Tier 3: Heal neighboring plants!
if (this.level === 3) {
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
View File
@@ -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();
+705 -73
View File
@@ -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;
};
// Row 0 defense: Peashooter at col 2 if (currentStage === 5) {
const b02 = this.grid.getCellBounds(2, 0); // Stage 5: RAID BOSS - Mega Sunflower (5000 HP) with Royal Defense!
const p02 = new window.Plant('peashooter', 2, 0, b02.x, b02.y, b02.w, b02.h); for (let r = 0; r < 5; r++) {
this.grid.setPlant(2, 0, p02); if (r === 2) {
this.plants.push(p02); addPlant('mega_sunflower', 0, r); // Center lane Raid Boss!
} else {
addPlant('sunflower', 0, r);
}
}
addPlant('snowpea', 2, 0);
addPlant('tallnut', 4, 0);
// Row 1 defense: Repeater at col 2, Wallnut at col 4 addPlant('repeater', 1, 1);
const b12 = this.grid.getCellBounds(2, 1); addPlant('repeater', 3, 1);
const p12 = new window.Plant('repeater', 2, 1, b12.x, b12.y, b12.w, b12.h); addPlant('tallnut', 4, 1);
this.grid.setPlant(2, 1, p12);
this.plants.push(p12);
const b14 = this.grid.getCellBounds(4, 1); // Lane 2: The Boss Lane
const p14 = new window.Plant('wallnut', 4, 1, b14.x, b14.y, b14.w, b14.h); addPlant('repeater', 1, 2);
this.grid.setPlant(4, 1, p14); addPlant('snowpea', 2, 2);
this.plants.push(p14); addPlant('tallnut', 4, 2);
// Row 2 defense: Snowpea at col 1, Peashooter at col 3 addPlant('repeater', 1, 3);
const b21 = this.grid.getCellBounds(1, 2); addPlant('repeater', 3, 3);
const p21 = new window.Plant('snowpea', 1, 2, b21.x, b21.y, b21.w, b21.h); addPlant('tallnut', 4, 3);
this.grid.setPlant(1, 2, p21);
this.plants.push(p21);
const b23 = this.grid.getCellBounds(3, 2); addPlant('snowpea', 2, 4);
const p23 = new window.Plant('peashooter', 3, 2, b23.x, b23.y, b23.w, b23.h); addPlant('tallnut', 4, 4);
this.grid.setPlant(3, 2, p23);
this.plants.push(p23);
// Row 3 defense: Peashooter at col 2, Wallnut at col 4 } else if (currentStage === 4) {
const b32 = this.grid.getCellBounds(2, 3); // Stage 4: The Great Wall (Tallnuts & repeaters)
const p32 = new window.Plant('peashooter', 2, 3, b32.x, b32.y, b32.w, b32.h); for (let r = 0; r < 5; r++) {
this.grid.setPlant(2, 3, p32); addPlant('sunflower', 0, r);
this.plants.push(p32); addPlant('repeater', 1, r);
addPlant('tallnut', 3, r);
}
addPlant('potatomine', 4, 1, { isArmed: true });
addPlant('potatomine', 4, 3, { isArmed: true });
const b34 = this.grid.getCellBounds(4, 3); } else if (currentStage === 3) {
const p34 = new window.Plant('wallnut', 4, 3, b34.x, b34.y, b34.w, b34.h); // Stage 3: Frost & Thorns (Snowpeas & armed mines)
this.grid.setPlant(4, 3, p34); for (let r = 0; r < 5; r++) {
this.plants.push(p34); addPlant('sunflower', 0, r);
}
addPlant('snowpea', 2, 0);
addPlant('wallnut', 4, 0);
// Row 4 defense: Repeater at col 1, Potato Mine at col 3 (armed) addPlant('repeater', 1, 1);
const b41 = this.grid.getCellBounds(1, 4); addPlant('snowpea', 3, 1);
const p41 = new window.Plant('repeater', 1, 4, b41.x, b41.y, b41.w, b41.h); addPlant('potatomine', 4, 1, { isArmed: true });
this.grid.setPlant(1, 4, p41);
this.plants.push(p41);
const b43 = this.grid.getCellBounds(3, 4); addPlant('snowpea', 2, 2);
const p43 = new window.Plant('potatomine', 3, 4, b43.x, b43.y, b43.w, b43.h); addPlant('tallnut', 4, 2);
p43.isArmed = true;
p43.armTimer = 15; addPlant('repeater', 1, 3);
this.grid.setPlant(3, 4, p43); addPlant('potatomine', 3, 3, { isArmed: true });
this.plants.push(p43); 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 });
}
} }
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> &bull; <strong>+2 💎 кристалла</strong> (Всего: ${this.economy.totalCoins})`; statsEl.innerHTML = `Триумф! Режим Boss Rush успешно завершён!<br>Награда: <strong>+500 💰 монет</strong> &bull; <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');
}); });
tabPerks.addEventListener('click', () => { if (btnCloseCasino && casinoModal) {
tabPerks.classList.add('active'); btnCloseCasino.addEventListener('click', () => {
tabPlants.classList.remove('active'); casinoModal.classList.add('hidden');
contentPerks.classList.remove('hidden'); this.updateRecordDisplay();
contentPlants.classList.add('hidden'); });
}
if (tabCasinoSlots && tabCasinoBank) {
tabCasinoSlots.addEventListener('click', () => {
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(' &bull; ');
}
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} &mdash; ${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} &mdash; ${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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('Закончились солнце и зомби! Растения уцелели.');
} }
} }
} }
+12
View File
@@ -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
View File
@@ -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 || {}) }
}
: (loc.zenGarden || def.zenGarden),
treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom,
achievements: {
...(loc.achievements || {}),
...(base.achievements || {})
} }
: def.zenGarden,
treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom,
achievements: serverData.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;
+75 -4
View File
@@ -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) {
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 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);
+261
View File
@@ -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}`
};
}
};
+5
View File
@@ -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
View File
@@ -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;
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+3 -1
View File
@@ -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
+189
View File
@@ -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;
}
};