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 @@
@@ -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 = `
+
+
+
+ Текущий бонус: ${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;
+ }
+};