diff --git a/game/css/style.css b/game/css/style.css index 0ca4fb3..46c61f9 100644 --- a/game/css/style.css +++ b/game/css/style.css @@ -1090,6 +1090,113 @@ canvas#gameCanvas { 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 */ .btn-workshop { background: linear-gradient(to bottom, #00897b, #004d40); @@ -2901,3 +3008,203 @@ body.theme-winter .banner-card { 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; +} + + diff --git a/game/index.html b/game/index.html index cb13daf..b24df3b 100644 --- a/game/index.html +++ b/game/index.html @@ -61,12 +61,14 @@ + + ⚙️ Конфигуратор & Админка @@ -130,6 +132,7 @@
+ @@ -189,6 +192,111 @@
+ + + + + +
@@ -502,6 +619,26 @@ + +
+
+
+ 📱 МОИ УСТРОЙСТВА И АКТИВНЫЕ СЕССИИ +
+ +
+ +
+
Загрузка активных сессий...
+
+ +
+ +
+
+
@@ -673,6 +810,7 @@ + @@ -683,6 +821,8 @@ + + diff --git a/game/js/entities/plant.js b/game/js/entities/plant.js index e88fbab..65d004f 100644 --- a/game/js/entities/plant.js +++ b/game/js/entities/plant.js @@ -21,6 +21,13 @@ window.Plant = class Plant { this.recoilDps = 0; switch (type) { + case 'mega_sunflower': + this.maxHealth = 5000; + this.sunInterval = 5; + this.sunAmount = 100; + this.sunTimer = 2; + this.isRaidBoss = true; + break; case 'sunflower': this.sunInterval = this.level === 3 ? 9 : (this.level === 2 ? 10 : 13); this.sunAmount = this.level >= 2 ? 35 : 25; @@ -108,6 +115,17 @@ window.Plant = class Plant { this.shootTimer = 1.0; 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; } @@ -119,15 +137,25 @@ window.Plant = class Plant { return; } - // 1. Sunflower & Sun-shroom - if (this.type === 'sunflower') { + // 1. Sunflower, Mega Sunflower & Sun-shroom + if (this.type === 'sunflower' || this.type === 'mega_sunflower') { this.sunTimer += dt; if (this.sunTimer >= this.sunInterval) { this.sunTimer = 0; gameState.spawnSun(this.x + (Math.random() * 20 - 10), this.y + (Math.random() * 20 - 10), this.sunAmount || 25, false); - // Tier 3: Heal neighboring plants! - if (this.level === 3) { + 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! 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) { p.health = Math.min(p.maxHealth, p.health + 30); @@ -426,6 +454,9 @@ window.Plant = class Plant { case 'melonpult': this.drawMelonpult(ctx); break; + case 'mega_sunflower': + this.drawMegaSunflower(ctx); + break; } // Tier Stars (Level 2 & 3) @@ -494,6 +525,150 @@ window.Plant = class Plant { 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) { const mainColor = isSnow ? '#29b6f6' : '#4caf50'; const darkColor = isSnow ? '#0288d1' : '#2e7d32'; diff --git a/game/js/entities/zombie.js b/game/js/entities/zombie.js index 04742b3..3755d22 100644 --- a/game/js/entities/zombie.js +++ b/game/js/entities/zombie.js @@ -58,15 +58,47 @@ window.Zombie = class Zombie { this.baseSpeed = 24; break; case 'flag': - this.maxHealth = 110; + this.maxHealth = 140; this.baseSpeed = 26; break; - case 'normal': default: this.maxHealth = 100; this.helmetHealth = 0; 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.health = this.maxHealth; } @@ -128,8 +160,13 @@ window.Zombie = class Zombie { this.x -= 150 * dt; if (this.vaultTimer >= 0.6) { this.isVaulting = false; - this.baseSpeed = 22; // Normal speed after jumping - this.speed = 22; + if (this.maxVaults && this.maxVaults > 1) { + this.maxVaults--; + this.hasPole = true; // Can vault second time! + } + const postSpeed = this.turboSprint ? 35 : 22; + this.baseSpeed = postSpeed; + this.speed = postSpeed; } return; } @@ -156,6 +193,9 @@ window.Zombie = class Zombie { if (this.eatTimer >= 0.35) { this.eatTimer = 0; 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 (this.targetPlant && this.targetPlant.type === 'wallnut' && gameState && gameState.achievements) { 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; - if (slows) { + if (slows && !this.iceImmune) { this.slowTimer = 4.0; } + if (this.reflectDmg && source && source.takeDamage) { + source.takeDamage(amount * this.reflectDmg); + } // Newspaper mechanics if (this.type === 'newspaper' && this.hasPaper) { @@ -511,8 +554,6 @@ window.Zombie = class Zombie { ctx.fill(); } - ctx.restore(); - // Frost Crystals visual effect around slowed / frozen zombie if (isSlow && !isHit) { this.drawFrostCrystals(ctx); @@ -530,13 +571,16 @@ window.Zombie = class Zombie { ctx.restore(); } - // Health bar if damaged + // Health bar if damaged (drawn directly above zombie head) if (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.fillStyle = pct > 0.4 ? '#8bc34a' : '#f44336'; ctx.fillRect(-20, -56, 40 * pct, 5); + ctx.strokeStyle = '#222'; + ctx.lineWidth = 0.8; + ctx.strokeRect(-20, -56, 40, 5); } ctx.restore(); diff --git a/game/js/game.js b/game/js/game.js index 3f62786..602a725 100644 --- a/game/js/game.js +++ b/game/js/game.js @@ -47,6 +47,12 @@ window.GameEngine = class GameEngine { this.feedback = window.PvZFeedback; 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.combat = new window.CombatSystem(); if (window.ProjectilePool) { @@ -97,6 +103,14 @@ window.GameEngine = class GameEngine { } 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 this.bindEvents(); this.bindUI(); @@ -131,6 +145,8 @@ window.GameEngine = class GameEngine { if (this.achievements) this.achievements.loadFromAuth(); if (this.tree) this.tree.loadFromAuth(); if (this.leaderboards) this.leaderboards.loadFromAuth(); + if (this.casino) this.casino.loadFromAuth(); + if (this.zombieLab) this.zombieLab.loadFromAuth(); } else { this.currentLevel = parseInt(localStorage.getItem('pvz_level') || '1', 10); this.maxUnlockedLevel = parseInt(localStorage.getItem('pvz_max_level') || this.currentLevel.toString(), 10); @@ -162,9 +178,12 @@ window.GameEngine = class GameEngine { saveUserData() { if (window.PvZAuth) { + if (this.workshop) this.workshop.saveToAuth(); if (this.achievements) this.achievements.saveToAuth(); if (this.tree) this.tree.saveToAuth(); if (this.leaderboards) this.leaderboards.saveToAuth(); + if (this.casino) this.casino.saveToAuth(); + if (this.zombieLab) this.zombieLab.saveToAuth(); window.PvZAuth.saveUserData({ coins: this.economy ? this.economy.totalCoins : undefined, level: this.currentLevel, @@ -319,11 +338,107 @@ window.GameEngine = class GameEngine { if (statPlants) statPlants.textContent = (stats.plantsPlanted || 0).toLocaleString('ru-RU'); if (statLevelsWon) statLevelsWon.textContent = (stats.levelsCompleted || 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 = '
Войдите в аккаунт для просмотра сессий
'; + return; + } + + const sessions = await window.PvZAuth.fetchSessions(); + if (!sessions || sessions.length === 0) { + listEl.innerHTML = '
Нет активных сессий
'; + 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() { this.isRunning = false; 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 ['game-over-modal', 'victory-modal', 'draft-modal', 'zengarden-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); if (el) el.classList.add('hidden'); }); @@ -417,75 +532,133 @@ window.GameEngine = class GameEngine { 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.lastTime = performance.now(); const musicTheme = (mode === 'boss' || mode === 'bossrush') ? 'boss' : this.wave.biome; if (window.PvZAudio) window.PvZAudio.startMusic(musicTheme); } - setupIZombieLawn() { + setupIZombieLawn(stage) { this.plants = []; 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) - for (let r = 0; r < 5; r++) { - const b0 = this.grid.getCellBounds(0, r); - const sf = new window.Plant('sunflower', 0, r, b0.x, b0.y, b0.w, b0.h); - this.grid.setPlant(0, r, sf); - this.plants.push(sf); + const addPlant = (type, col, row, options = {}) => { + const b = this.grid.getCellBounds(col, row); + const p = new window.Plant(type, col, row, b.x, b.y, b.w, b.h); + if (options.isArmed) { + p.isArmed = true; + p.armTimer = 15; + } + this.grid.setPlant(col, row, p); + this.plants.push(p); + return p; + }; + + if (currentStage === 5) { + // Stage 5: RAID BOSS - Mega Sunflower (5000 HP) with Royal Defense! + for (let r = 0; r < 5; r++) { + if (r === 2) { + addPlant('mega_sunflower', 0, r); // Center lane Raid Boss! + } else { + addPlant('sunflower', 0, r); + } + } + addPlant('snowpea', 2, 0); + addPlant('tallnut', 4, 0); + + addPlant('repeater', 1, 1); + addPlant('repeater', 3, 1); + addPlant('tallnut', 4, 1); + + // Lane 2: The Boss Lane + addPlant('repeater', 1, 2); + addPlant('snowpea', 2, 2); + addPlant('tallnut', 4, 2); + + addPlant('repeater', 1, 3); + addPlant('repeater', 3, 3); + addPlant('tallnut', 4, 3); + + addPlant('snowpea', 2, 4); + addPlant('tallnut', 4, 4); + + } else if (currentStage === 4) { + // Stage 4: The Great Wall (Tallnuts & repeaters) + for (let r = 0; r < 5; r++) { + addPlant('sunflower', 0, r); + addPlant('repeater', 1, r); + addPlant('tallnut', 3, r); + } + addPlant('potatomine', 4, 1, { isArmed: true }); + addPlant('potatomine', 4, 3, { isArmed: true }); + + } else if (currentStage === 3) { + // Stage 3: Frost & Thorns (Snowpeas & armed mines) + for (let r = 0; r < 5; r++) { + addPlant('sunflower', 0, r); + } + addPlant('snowpea', 2, 0); + addPlant('wallnut', 4, 0); + + addPlant('repeater', 1, 1); + addPlant('snowpea', 3, 1); + addPlant('potatomine', 4, 1, { isArmed: true }); + + addPlant('snowpea', 2, 2); + addPlant('tallnut', 4, 2); + + addPlant('repeater', 1, 3); + addPlant('potatomine', 3, 3, { isArmed: true }); + addPlant('wallnut', 4, 3); + + addPlant('snowpea', 2, 4); + addPlant('chomper', 4, 4); + + } else if (currentStage === 2) { + // Stage 2: Night Guard (Mushrooms & Peashooters) + for (let r = 0; r < 5; r++) { + addPlant('sunflower', 0, r); + } + addPlant('peashooter', 1, 0); + addPlant('fumeshroom', 3, 0); + + addPlant('snowpea', 2, 1); + addPlant('wallnut', 4, 1); + + addPlant('repeater', 1, 2); + addPlant('repeater', 3, 2); + addPlant('wallnut', 5, 2); + + addPlant('fumeshroom', 2, 3); + addPlant('chomper', 4, 3); + + addPlant('peashooter', 1, 4); + addPlant('potatomine', 3, 4, { isArmed: true }); + + } else { + // Stage 1: Classic Lawn + for (let r = 0; r < 5; r++) { + addPlant('sunflower', 0, r); + } + addPlant('peashooter', 2, 0); + + addPlant('repeater', 2, 1); + addPlant('wallnut', 4, 1); + + addPlant('snowpea', 1, 2); + addPlant('peashooter', 3, 2); + + addPlant('peashooter', 2, 3); + addPlant('wallnut', 4, 3); + + addPlant('repeater', 1, 4); + addPlant('potatomine', 3, 4, { isArmed: true }); } - - // Row 0 defense: Peashooter at col 2 - const b02 = this.grid.getCellBounds(2, 0); - const p02 = new window.Plant('peashooter', 2, 0, b02.x, b02.y, b02.w, b02.h); - this.grid.setPlant(2, 0, p02); - this.plants.push(p02); - - // Row 1 defense: Repeater at col 2, Wallnut at col 4 - const b12 = this.grid.getCellBounds(2, 1); - const p12 = new window.Plant('repeater', 2, 1, b12.x, b12.y, b12.w, b12.h); - this.grid.setPlant(2, 1, p12); - this.plants.push(p12); - - const b14 = this.grid.getCellBounds(4, 1); - const p14 = new window.Plant('wallnut', 4, 1, b14.x, b14.y, b14.w, b14.h); - this.grid.setPlant(4, 1, p14); - this.plants.push(p14); - - // Row 2 defense: Snowpea at col 1, Peashooter at col 3 - const b21 = this.grid.getCellBounds(1, 2); - const p21 = new window.Plant('snowpea', 1, 2, b21.x, b21.y, b21.w, b21.h); - this.grid.setPlant(1, 2, p21); - this.plants.push(p21); - - const b23 = this.grid.getCellBounds(3, 2); - const p23 = new window.Plant('peashooter', 3, 2, b23.x, b23.y, b23.w, b23.h); - this.grid.setPlant(3, 2, p23); - this.plants.push(p23); - - // Row 3 defense: Peashooter at col 2, Wallnut at col 4 - const b32 = this.grid.getCellBounds(2, 3); - const p32 = new window.Plant('peashooter', 2, 3, b32.x, b32.y, b32.w, b32.h); - this.grid.setPlant(2, 3, p32); - this.plants.push(p32); - - const b34 = this.grid.getCellBounds(4, 3); - const p34 = new window.Plant('wallnut', 4, 3, b34.x, b34.y, b34.w, b34.h); - this.grid.setPlant(4, 3, p34); - this.plants.push(p34); - - // Row 4 defense: Repeater at col 1, Potato Mine at col 3 (armed) - const b41 = this.grid.getCellBounds(1, 4); - const p41 = new window.Plant('repeater', 1, 4, b41.x, b41.y, b41.w, b41.h); - this.grid.setPlant(1, 4, p41); - this.plants.push(p41); - - const b43 = this.grid.getCellBounds(3, 4); - const p43 = new window.Plant('potatomine', 3, 4, b43.x, b43.y, b43.w, b43.h); - p43.isArmed = true; - p43.armTimer = 15; - this.grid.setPlant(3, 4, p43); - this.plants.push(p43); } showDaveDialogue(level, mode, onContinue) { @@ -593,6 +766,15 @@ window.GameEngine = class GameEngine { const zombie = new window.Zombie(row, type); const bounds = this.grid.getCellBounds(0, row); 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); } @@ -661,7 +843,7 @@ window.GameEngine = class GameEngine { this.economy.addCoins(100); // 100 coin bonus for level completion! // 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'); } @@ -690,6 +872,27 @@ window.GameEngine = class GameEngine { // Save level progression 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') { const campLevel = window.CampaignSystem ? window.CampaignSystem.getLevel(this.currentLevel) : null; if (campLevel && campLevel.reward) { @@ -726,7 +929,7 @@ window.GameEngine = class GameEngine { const statsEl = document.getElementById('victory-stats'); if (statsEl) { if (this.currentMode === 'izombie') { - statsEl.innerHTML = `Победа! Все подсолнухи съедены! Мозги захвачены!
Награда: +100 💰 монет (Всего: ${this.economy.totalCoins})`; + statsEl.innerHTML = `Победа! Все цели уничтожены! Мозги захвачены!
Этап «Я, Зомби»: Этап ${izombieCurrentStage} из 5${izombieNextUnlocked ? ' (🎉 Открыт следующий этап!)' : ''}
Награда: +${izombieRewardCoins} 💰 монет, +6 ⚙️ деталей, +4 🧪 биогумуса (Всего: ${this.economy.totalCoins})`; } else if (this.currentMode === 'bossrush') { statsEl.innerHTML = `Триумф! Режим Boss Rush успешно завершён!
Награда: +500 💰 монет • +2 💎 кристалла (Всего: ${this.economy.totalCoins})`; } else if (this.currentMode === 'adventure' && window.CampaignSystem) { @@ -1174,6 +1377,11 @@ window.GameEngine = class GameEngine { authModal.classList.remove('hidden'); if (authUser && window.PvZAuth && window.PvZAuth.isGuest()) { 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 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { @@ -1523,8 +1774,10 @@ window.GameEngine = class GameEngine { const btnCloseWorkshop = document.getElementById('btn-close-workshop'); const tabPlants = document.getElementById('tab-plants'); const tabPerks = document.getElementById('tab-perks'); + const tabZombies = document.getElementById('tab-zombies'); const contentPlants = document.getElementById('ws-content-plants'); const contentPerks = document.getElementById('ws-content-perks'); + const contentZombies = document.getElementById('ws-content-zombies'); const openWorkshop = () => { this.updateWorkshopUI(); @@ -1540,18 +1793,194 @@ window.GameEngine = class GameEngine { }); } - if (tabPlants && tabPerks && contentPlants && contentPerks) { - tabPlants.addEventListener('click', () => { - tabPlants.classList.add('active'); - tabPerks.classList.remove('active'); - contentPlants.classList.remove('hidden'); - contentPerks.classList.add('hidden'); + const switchWorkshopTab = (tab) => { + if (tabPlants) tabPlants.classList.toggle('active', tab === 'plants'); + if (tabPerks) tabPerks.classList.toggle('active', tab === 'perks'); + if (tabZombies) tabZombies.classList.toggle('active', tab === 'zombies'); + + if (contentPlants) contentPlants.classList.toggle('hidden', tab !== 'plants'); + if (contentPerks) contentPerks.classList.toggle('hidden', tab !== 'perks'); + if (contentZombies) contentZombies.classList.toggle('hidden', tab !== 'zombies'); + }; + + if (tabPlants) tabPlants.addEventListener('click', () => switchWorkshopTab('plants')); + if (tabPerks) tabPerks.addEventListener('click', () => switchWorkshopTab('perks')); + if (tabZombies) tabZombies.addEventListener('click', () => switchWorkshopTab('zombies')); + + // Casino Modal Handlers + const casinoModal = document.getElementById('casino-modal'); + const btnCasinoStart = document.getElementById('btn-casino-start'); + const btnCasinoDrawer = document.getElementById('btn-drawer-casino'); + const btnCloseCasino = document.getElementById('btn-close-casino'); + const tabCasinoSlots = document.getElementById('tab-casino-slots'); + const tabCasinoBank = document.getElementById('tab-casino-bank'); + const contentCasinoSlots = document.getElementById('casino-content-slots'); + const contentCasinoBank = document.getElementById('casino-content-bank'); + + const openCasino = () => { + this.updateCasinoUI(); + if (casinoModal) casinoModal.classList.remove('hidden'); + }; + + if (btnCasinoStart) btnCasinoStart.addEventListener('click', openCasino); + if (btnCasinoDrawer) btnCasinoDrawer.addEventListener('click', () => { + openCasino(); + const drawer = document.getElementById('mobile-drawer'); + if (drawer) drawer.classList.add('hidden'); + }); + if (btnCloseCasino && casinoModal) { + btnCloseCasino.addEventListener('click', () => { + casinoModal.classList.add('hidden'); + this.updateRecordDisplay(); }); - tabPerks.addEventListener('click', () => { - tabPerks.classList.add('active'); - tabPlants.classList.remove('active'); - contentPerks.classList.remove('hidden'); - 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 = `${res.icons[0]}`; + if (reel2) reel2.innerHTML = `${res.icons[1]}`; + if (reel3) reel3.innerHTML = `${res.icons[2]}`; + } + + 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 => { const isBasic = this.economy.defaultUnlocked.includes(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'); card.className = 'shop-plant-card'; @@ -2096,14 +2527,14 @@ window.GameEngine = class GameEngine { } else if (isUnlocked) { actionHtml = 'Куплено ✅'; } else { - actionHtml = ``; + actionHtml = ``; } card.innerHTML = `
${p.icon}

${p.name}

-
☀️ ${p.cost} солнца
+
☀️ ${sunCost} солнца

${p.desc}

${actionHtml}
@@ -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 += ` + + `; + }); + + const curStageDef = this.zombieLab.stages.find(s => s.id === selStage) || this.zombieLab.stages[0]; + + stageBanner.innerHTML = ` +
+
+

🗺️ Кампания «Я, Зомби» (Этап ${selStage} из 5)

+

${curStageDef.title}: ${curStageDef.desc}

+
+
+ ${stageBtnsHtml} +
+
+ `; + container.appendChild(stageBanner); + + // Bind stage select buttons + stageBanner.querySelectorAll('.btn-stage-select').forEach(btn => { + btn.addEventListener('click', (e) => { + const stId = parseInt(e.currentTarget.getAttribute('data-stage-id'), 10); + this.zombieLab.selectedStage = stId; + this.renderZombieLabUI(); + }); + }); + + // Zombie Mutation Cards + for (const [key, zDef] of Object.entries(this.zombieLab.zombies)) { + const curTier = this.zombieLab.getZombieLevel(key); + const isMax = curTier >= 3; + const curData = zDef.tiers[curTier] || zDef.tiers[1]; + const nextData = isMax ? null : zDef.tiers[curTier + 1]; + + const card = document.createElement('div'); + card.className = 'upgrade-card zombie-tier-card'; + + let costHtml = ''; + if (nextData && nextData.cost) { + const parts = []; + if (nextData.cost.coins) parts.push(`💰 ${nextData.cost.coins}`); + if (nextData.cost.scrapMetal) parts.push(`⚙️ ${nextData.cost.scrapMetal}`); + if (nextData.cost.bioHumus) parts.push(`🧪 ${nextData.cost.bioHumus}`); + if (nextData.cost.crystals) parts.push(`💎 ${nextData.cost.crystals}`); + costHtml = parts.join(' • '); + } + + const stars = '★'.repeat(curTier) + '☆'.repeat(3 - curTier); + const canAfford = this.zombieLab.canUpgrade(key); + + card.innerHTML = ` +
+
${zDef.icon}
+
+

${zDef.name}

+
${stars} (Ур. ${curTier}/3)
+
+
Ранг ${curTier}
+
+ +
+ Текущий бонус: ${curData.title} — ${curData.desc} +
+ + ${!isMax ? ` +
+ Мутация до Ур. ${curTier + 1}: ${nextData.title} — ${nextData.desc} +
+ + ` : ` +
+ 🧬 Максимальная мутация достигнута! +
+ `} + `; + + 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 = ` +
🧅
+
+
«THIS IS MY SWAMP!»
+
ШРЕК СОТРЯС ЗЕМЛЮ! 2000 урона всем зомби! (${affected} поражено)
+
+ `; + toast.classList.add('visible'); + setTimeout(() => toast.classList.remove('visible'), 4000); } initZenGarden() { @@ -2756,6 +3374,16 @@ window.GameEngine = class GameEngine { return; } + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + let rowsHtml = ''; entries.forEach((item, index) => { let medal = `#${index + 1}`; @@ -2763,12 +3391,16 @@ window.GameEngine = class GameEngine { else if (index === 1) medal = '🥈 2'; else if (index === 2) medal = '🥉 3'; + const safeName = escapeHtml(item.name || 'Игрок'); + const safeDetail = escapeHtml(item.detail || item.score || ''); + const safeDate = escapeHtml(item.date || ''); + rowsHtml += ` ${medal} - ${item.name} - ${item.detail || item.score} - ${item.date || ''} + ${safeName} + ${safeDetail} + ${safeDate} `; }); @@ -3117,14 +3749,14 @@ window.GameEngine = class GameEngine { // 9. I, Zombie Win & Defeat Check if (this.currentMode === 'izombie' && this.izombieSetupDone && this.isRunning) { - const sunflowers = this.plants.filter(p => p.type === 'sunflower'); - if (sunflowers.length === 0) { + const targets = this.plants.filter(p => p.type === 'sunflower' || p.type === 'mega_sunflower'); + if (targets.length === 0) { this.triggerVictory(); } else { const minZombieCost = 50; const hasAliveZombies = this.zombies.some(z => z.health > 0 && !z.toRemove); if (!hasAliveZombies && this.economy.sun < minZombieCost) { - this.triggerGameOver('Закончились солнце и зомби! Подсолнухи уцелели.'); + this.triggerGameOver('Закончились солнце и зомби! Растения уцелели.'); } } } diff --git a/game/js/main.js b/game/js/main.js index a53d616..a7aceb0 100644 --- a/game/js/main.js +++ b/game/js/main.js @@ -5,6 +5,18 @@ document.addEventListener('DOMContentLoaded', () => { 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() { if (startModal) { startModal.classList.add('hidden'); diff --git a/game/js/systems/auth.js b/game/js/systems/auth.js index bcc63a3..49ce2a5 100644 --- a/game/js/systems/auth.js +++ b/game/js/systems/auth.js @@ -131,6 +131,52 @@ window.PvZAuth = (function () { 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 ─── */ async function apiPost(endpoint, body = {}, keepalive = false) { try { @@ -172,41 +218,90 @@ window.PvZAuth = (function () { /* ─── Debounced save (prevents save storms on rapid updates) ─── */ let _saveTimer = null; + let _hasSyncedFromServer = false; + function scheduleSave() { if (_saveTimer) clearTimeout(_saveTimer); - _saveTimer = setTimeout(() => { + _saveTimer = setTimeout(async () => { const user = localStorage.getItem(CURRENT_USER_KEY); if (!user || user === 'Гость' || !_cachedData) return; + if (!_hasSyncedFromServer && getToken()) { + await authInstance.fetchProfile(); + } apiPost('save', { username: user, data: _cachedData }); }, 1500); } - function mergeUserData(serverData) { + function mergeUserData(serverData, localData = null) { 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 { ...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: { ...def.stats, - ...(serverData.stats || {}) + ...(loc.stats || {}), + ...(base.stats || {}) }, - upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) }, - unlockedPlants: serverData.unlockedPlants || def.unlockedPlants, - selectedDeck: serverData.selectedDeck || def.selectedDeck, - materials: { ...def.materials, ...(serverData.materials || {}) }, - plantLevels: { ...def.plantLevels, ...(serverData.plantLevels || {}) }, - perks: { ...def.perks, ...(serverData.perks || {}) }, - zenGarden: serverData.zenGarden + upgrades: { + ...def.upgrades, + ...(loc.upgrades || {}), + ...(base.upgrades || {}), + unlockedPlants: unlocked, + selectedDeck: selDeck + }, + 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, - ...serverData.zenGarden, - pots: serverData.zenGarden.pots || def.zenGarden.pots, - snail: { ...def.zenGarden.snail, ...(serverData.zenGarden.snail || {}) } + ...(loc.zenGarden || {}), + ...base.zenGarden, + pots: base.zenGarden.pots || (loc.zenGarden && loc.zenGarden.pots) || def.zenGarden.pots, + snail: { ...def.zenGarden.snail, ...((loc.zenGarden && loc.zenGarden.snail) || {}), ...(base.zenGarden.snail || {}) } } - : def.zenGarden, - treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom, - achievements: serverData.achievements || {} + : (loc.zenGarden || def.zenGarden), + treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom, + achievements: { + ...(loc.achievements || {}), + ...(base.achievements || {}) + } }; } @@ -228,9 +323,13 @@ window.PvZAuth = (function () { getDefaultData, validateUsername, validatePassword, + apiGet, apiPost, scheduleSave, showNotification, + onProfileChange, + offProfileChange, + notifyProfileChange, /** * User Login @@ -263,7 +362,11 @@ window.PvZAuth = (function () { if (window.GameEngine && 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); if (typeof callback === 'function') callback(true, _cachedData, null); return { success: true, username: cleanUser, token: res.token, data: _cachedData }; } else { @@ -305,7 +408,11 @@ window.PvZAuth = (function () { if (window.GameEngine && 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); if (typeof callback === 'function') callback(true, _cachedData, null); return { success: true, username: cleanUser, token: res.token, data: _cachedData }; } else { @@ -318,6 +425,7 @@ window.PvZAuth = (function () { loginAsGuest() { clearToken(); localStorage.setItem(CURRENT_USER_KEY, 'Гость'); + notifyProfileChange(this.getUserData()); return { success: true, user: 'Гость' }; }, @@ -334,16 +442,118 @@ window.PvZAuth = (function () { 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() { if (_cachedData) return _cachedData; const local = localLoad(); if (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; }, @@ -362,6 +572,7 @@ window.PvZAuth = (function () { /** Force immediate server save (call on game-over / victory / visibility hidden / unload) */ flushSave(keepalive = false) { if (this.isGuest() || !_cachedData) return; + if (!_hasSyncedFromServer && getToken()) return; // Don't flush stale unsynced local cache over server if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive); }, @@ -527,6 +738,13 @@ window.PvZAuth = (function () { } authInstance.flushSave(true); }); + + // Auto-sync user data on startup if logged in + setTimeout(() => { + if (!authInstance.isGuest() && authInstance.getToken()) { + authInstance.syncFromServer(); + } + }, 150); } return authInstance; diff --git a/game/js/systems/campaign.js b/game/js/systems/campaign.js index 4726f4d..4dd25bd 100644 --- a/game/js/systems/campaign.js +++ b/game/js/systems/campaign.js @@ -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 } ]; + 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 { WORLDS, LEVELS, DAVE_DIALOGUES, + loadLevelsFromServer, + isServerLoaded() { + return _serverLoaded; + }, + getAllLevels() { + return _activeLevels; + }, getDaveDialogue(levelNum, mode = 'adventure') { if (mode === 'boss' || mode === 'bossrush') { return DAVE_DIALOGUES['5-5']; } const num = parseInt(levelNum, 10) || 1; - const levelObj = LEVELS.find(l => l.level === num); - if (levelObj && DAVE_DIALOGUES[levelObj.code]) { - return DAVE_DIALOGUES[levelObj.code]; + const levelObj = this.getLevel(num); + 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 null; }, getLevel(levelNum) { 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) { return WORLDS.find(w => w.id === worldId) || WORLDS[0]; }, getLevelsByWorld(worldId) { - return LEVELS.filter(l => l.world === worldId); + return _activeLevels.filter(l => l.world === worldId); }, formatLevel(levelNum) { const item = this.getLevel(levelNum); diff --git a/game/js/systems/casino.js b/game/js/systems/casino.js new file mode 100644 index 0000000..f09450a --- /dev/null +++ b/game/js/systems/casino.js @@ -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}` + }; + } +}; diff --git a/game/js/systems/combat.js b/game/js/systems/combat.js index 3af056b..e75b77b 100644 --- a/game/js/systems/combat.js +++ b/game/js/systems/combat.js @@ -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; // Coins Drop diff --git a/game/js/systems/economy.js b/game/js/systems/economy.js index 2a270d5..c43db29 100644 --- a/game/js/systems/economy.js +++ b/game/js/systems/economy.js @@ -21,7 +21,7 @@ window.PLANT_CATALOGUE = [ // Economy, Sun, Coin Drops & Crazy Dave's Shop System window.EconomySystem = class EconomySystem { constructor() { - this.sun = 50; + this.sun = window.PvZConfig ? window.PvZConfig.getStartingSun() : 50; this.suns = []; this.coins = []; this.skySunTimer = 0; @@ -42,7 +42,7 @@ window.EconomySystem = class EconomySystem { this.allCards = window.PLANT_CATALOGUE.map(p => ({ id: p.id, name: p.name, - cost: p.cost, + cost: window.PvZConfig ? window.PvZConfig.getPlantSunCost(p.id, p.cost) : p.cost, cooldown: p.cooldown, timer: 0 })); @@ -69,7 +69,7 @@ window.EconomySystem = class EconomySystem { if (!p) 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) { return { success: false, message: `Недостаточно монет! Требуется ${price} 💰 (у вас: ${this.totalCoins} 💰)` }; } @@ -270,7 +270,7 @@ window.EconomySystem = class EconomySystem { } 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) { baseSun += this.workshop.getPerkLevel('startSun') * 50; } @@ -330,11 +330,13 @@ window.EconomySystem = class EconomySystem { } 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({ x, y, targetY: targetY !== null ? targetY : y, - value, + value: finalVal, isFalling, fallSpeed: 90, radius: 26, @@ -346,12 +348,14 @@ window.EconomySystem = class EconomySystem { } 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({ x, y, type, - value: val, + value: finalVal, radius: 16, life: 12.0, collecting: false, @@ -363,9 +367,10 @@ window.EconomySystem = class EconomySystem { update(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 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) { if (card.timer > 0) { 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) if (!this.isNight && !this.isConveyor) { 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; const spawnX = 140 + Math.random() * 640; const targetY = 140 + Math.random() * 380; diff --git a/game/js/systems/leaderboard.js b/game/js/systems/leaderboard.js index 8e6eb8f..0f86fc9 100644 --- a/game/js/systems/leaderboard.js +++ b/game/js/systems/leaderboard.js @@ -40,6 +40,7 @@ window.LeaderboardSystem = class LeaderboardSystem { }; this.loadFromAuth(); + this.fetchAllLeaderboards(); } 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 = '') { if (!this.records[mode]) { this.records[mode] = []; } let playerName = 'Игрок'; + let token = null; if (window.PvZAuth) { playerName = window.PvZAuth.getCurrentUser(); if (window.PvZAuth.isGuest()) playerName = 'Гость'; + token = window.PvZAuth.getToken(); } const today = new Date().toISOString().slice(0, 10); @@ -94,6 +157,23 @@ window.LeaderboardSystem = class LeaderboardSystem { // Keep top 10 this.records[mode] = this.records[mode].slice(0, 10); 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) { diff --git a/game/js/systems/tree.js b/game/js/systems/tree.js index 01955ec..e2c8452 100644 --- a/game/js/systems/tree.js +++ b/game/js/systems/tree.js @@ -12,7 +12,8 @@ window.TreeSystem = class TreeSystem { future: false, daisies: false, dance: false, - pinata: false + pinata: false, + shrek: false }; // Wisdom unlocked at various heights @@ -50,6 +51,12 @@ window.TreeSystem = class TreeSystem { text: '«Смерть нежити может приносить радость. Введи код daisies — при падении зомби на земле будут расцветать прекрасные маргаритки!»', cheat: 'daisies' }, + { + height: 60, + title: 'Секретный код: SHREK', + text: '«Легенда болот гласит: введи shrek во время боя, и гигантский зелёный великан сокрушит орду зомби землетрясением!»', + cheat: 'shrek' + }, { height: 75, title: 'Секрет Доктора Зомбосса', @@ -124,10 +131,13 @@ window.TreeSystem = class TreeSystem { buffer += char; 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)) { const state = this.toggleCheat(code); this.showCheatToast(code, state); + if (code === 'shrek' && window._pvzEngine && window._pvzEngine.isRunning) { + window._pvzEngine.triggerShrekEasterEgg(); + } buffer = ''; break; } diff --git a/game/js/systems/wave.js b/game/js/systems/wave.js index 720ba9b..8710b27 100644 --- a/game/js/systems/wave.js +++ b/game/js/systems/wave.js @@ -49,7 +49,17 @@ window.WaveSystem = class WaveSystem { if (campLvl.isBoss) { 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 { + this.customWavesConfig = null; + this.modifiers = {}; + this.speedMultiplier = 1.0; + this.hpMultiplier = 1.0; + this.startingSun = null; if (level <= 5) { this.biome = 'day'; this.totalWaves = (level === 1) ? 3 : 4; @@ -209,7 +219,74 @@ window.WaveSystem = class WaveSystem { 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) { this.bannerText = 'ПЕРВЫЕ ЗОМБИ...'; this.bannerTimer = 3.0; diff --git a/game/js/systems/zengarden.js b/game/js/systems/zengarden.js index f2212c4..a5249dd 100644 --- a/game/js/systems/zengarden.js +++ b/game/js/systems/zengarden.js @@ -205,12 +205,14 @@ window.ZenGardenSystem = class ZenGardenSystem { const baseX = 120 + potCol * 150; 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({ x: baseX + (Math.random() * 30 - 15), y: baseY + 30, vy: -140, type: coinType, - value: coinType === 'diamond' ? 200 : (coinType === 'gold' ? 50 : 10), + value: Math.max(1, Math.round(baseVal * dropMult)), collected: false, alpha: 1.0, anim: 0 diff --git a/game/js/systems/zombielab.js b/game/js/systems/zombielab.js new file mode 100644 index 0000000..925c926 --- /dev/null +++ b/game/js/systems/zombielab.js @@ -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; + } +};