Files
ai-qpid-pvz/game/js/game.js
T

2707 lines
100 KiB
JavaScript

// Main Game Engine Controller (Sprint v1.2 - v1.5 Complete PvZ Platform)
window.GameEngine = class GameEngine {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
// Logical dimensions
this.logicalWidth = 900;
this.logicalHeight = 600;
this.width = this.logicalWidth;
this.height = this.logicalHeight;
// HiDPI / Retina setup
this.setupHiDPI();
window.addEventListener('resize', () => this.setupHiDPI());
// Systems
this.grid = new window.GridSystem();
this.economy = new window.EconomySystem();
this.economy.engine = this;
if (window.WorkshopSystem) {
this.workshop = new window.WorkshopSystem(this.economy);
this.economy.workshop = this.workshop;
}
if (window.ZenGardenSystem) {
this.zengarden = new window.ZenGardenSystem(this.economy);
}
if (window.AlmanacSystem) {
this.almanac = new window.AlmanacSystem();
}
if (window.AchievementSystem) {
this.achievements = new window.AchievementSystem(this);
}
if (window.RoguelikeSystem) {
this.roguelike = new window.RoguelikeSystem(this);
}
if (window.TreeSystem) {
this.tree = new window.TreeSystem(this.economy);
}
if (window.BossSystem) {
this.boss = new window.BossSystem(this);
}
if (window.LeaderboardSystem) {
this.leaderboards = new window.LeaderboardSystem();
}
if (window.PvZFeedback) {
this.feedback = window.PvZFeedback;
this.feedback.init();
}
this.wave = new window.WaveSystem();
this.combat = new window.CombatSystem();
// Entities
this.plants = [];
this.zombies = [];
this.projectiles = [];
// State
this.currentMode = 'adventure'; // 'adventure', 'survival', 'bowling', 'izombie', 'roguelike'
this.currentLevel = 1;
this.maxUnlockedLevel = 1;
this.isRunning = false;
this.isGameOver = false;
this.isVictory = false;
this.gameSpeed = 1.0;
this.isPaused = false;
this.lastTime = 0;
this.hoverCell = null;
this.loopStarted = false;
this.izombieSetupDone = false;
// Rake state
this.hasRakeArmed = false;
this.rakeCol = 7;
this.rakeRow = 2;
// Score & Persistence
this.score = 0;
this.zombiesKilled = 0;
this.loadUserData();
this.updateRecordDisplay();
this.updateProfileUI();
// Register with Test API
if (window.PvZTestAPI) {
window.PvZTestAPI.setEngine(this);
}
window._pvzEngine = this; // allow auth.js to trigger profile reload
// Bind events
this.bindEvents();
this.bindUI();
}
setupHiDPI() {
this.dpr = Math.min(window.devicePixelRatio || 1, 2.5);
this.canvas.width = this.logicalWidth * this.dpr;
this.canvas.height = this.logicalHeight * this.dpr;
this.ctx.resetTransform();
this.ctx.scale(this.dpr, this.dpr);
}
loadUserData() {
if (window.PvZAuth) {
const uData = window.PvZAuth.getUserData();
this.currentLevel = uData.level || 1;
this.maxUnlockedLevel = uData.maxUnlockedLevel || Math.max(this.currentLevel, 1);
this.highScore = uData.highScore || 0;
this.currentTheme = uData.theme || 'classic';
this.applyTheme(this.currentTheme);
if (this.economy) this.economy.reloadProfileData();
if (this.workshop) this.workshop.loadFromAuth();
if (this.zengarden) this.zengarden.loadFromAuth();
if (this.achievements) this.achievements.loadFromAuth();
if (this.tree) this.tree.loadFromAuth();
if (this.leaderboards) this.leaderboards.loadFromAuth();
} else {
this.currentLevel = parseInt(localStorage.getItem('pvz_level') || '1', 10);
this.maxUnlockedLevel = parseInt(localStorage.getItem('pvz_max_level') || this.currentLevel.toString(), 10);
this.highScore = parseInt(localStorage.getItem('pvz_highscore') || '0', 10);
this.currentTheme = localStorage.getItem('pvz_theme') || 'classic';
this.applyTheme(this.currentTheme);
}
}
saveUserData() {
if (window.PvZAuth) {
if (this.achievements) this.achievements.saveToAuth();
if (this.tree) this.tree.saveToAuth();
if (this.leaderboards) this.leaderboards.saveToAuth();
window.PvZAuth.saveUserData({
coins: this.economy ? this.economy.totalCoins : undefined,
level: this.currentLevel,
maxUnlockedLevel: this.maxUnlockedLevel,
highScore: this.highScore,
theme: this.currentTheme,
upgrades: this.economy ? this.economy.upgrades : undefined,
materials: this.workshop ? this.workshop.materials : undefined,
plantLevels: this.workshop ? this.workshop.plantLevels : undefined,
perks: this.workshop ? this.workshop.perks : undefined,
zenGarden: this.zengarden ? {
pots: this.zengarden.pots,
fertilizerCount: this.zengarden.fertilizerCount,
chocolateCount: this.zengarden.chocolateCount,
snail: this.zengarden.snail,
lastVisit: Date.now()
} : undefined
});
}
localStorage.setItem('pvz_level', this.currentLevel.toString());
localStorage.setItem('pvz_max_level', this.maxUnlockedLevel.toString());
localStorage.setItem('pvz_highscore', this.highScore.toString());
localStorage.setItem('pvz_theme', this.currentTheme);
if (this.economy) {
localStorage.setItem('pvz_coins', this.economy.totalCoins.toString());
}
}
applyTheme(theme) {
this.currentTheme = theme || 'classic';
const body = document.body;
body.classList.remove('theme-cyberpunk', 'theme-retro8bit', 'theme-winter');
if (this.currentTheme !== 'classic') {
body.classList.add(`theme-${this.currentTheme}`);
}
const btnTheme = document.getElementById('btn-theme');
if (btnTheme) {
const labels = {
classic: '🎨 Классика',
cyberpunk: '🎨 Киберпанк',
retro8bit: '🎨 Ретро 8-бит',
winter: '🎨 Зима'
};
btnTheme.textContent = labels[this.currentTheme] || '🎨 Тема';
}
if (this.grid) {
this.grid.theme = this.currentTheme;
}
}
cycleTheme() {
const themes = ['classic', 'cyberpunk', 'retro8bit', 'winter'];
const nextIdx = (themes.indexOf(this.currentTheme) + 1) % themes.length;
this.applyTheme(themes[nextIdx]);
this.saveUserData();
if (window.PvZAudio) window.PvZAudio.playCoin();
}
togglePause() {
if (!this.isRunning) return;
this.isPaused = !this.isPaused;
const btnPause = document.getElementById('btn-pause');
if (btnPause) {
btnPause.textContent = this.isPaused ? '▶️ Играть' : '⏸️ Пауза';
if (this.isPaused) btnPause.classList.add('active');
else btnPause.classList.remove('active');
}
this.triggerHaptic(15);
if (window.PvZAudio) window.PvZAudio.playButtonClick();
}
updateProfileUI() {
const badgeName = document.getElementById('badge-user-name');
const btnAuth = document.getElementById('btn-auth-open');
const btnLogoutNav = document.getElementById('btn-auth-logout-nav');
const loggedInView = document.getElementById('auth-logged-in-view');
const loginView = document.getElementById('auth-login-view') || document.getElementById('auth-guest-view');
const registerView = document.getElementById('auth-register-view');
const userTitle = document.getElementById('profile-user-title');
const statCoins = document.getElementById('profile-stat-coins');
const statLevel = document.getElementById('profile-stat-level');
const statScore = document.getElementById('profile-stat-score');
const statMaterials = document.getElementById('profile-stat-materials');
if (window.PvZAuth) {
const user = window.PvZAuth.getCurrentUser();
const isGuest = window.PvZAuth.isGuest();
const uData = window.PvZAuth.getUserData();
if (badgeName) badgeName.textContent = isGuest ? '👤 Гость' : `👤 ${user}`;
if (btnAuth) btnAuth.textContent = isGuest ? 'Войти' : 'Профиль';
if (btnLogoutNav) {
if (isGuest) btnLogoutNav.classList.add('hidden');
else btnLogoutNav.classList.remove('hidden');
}
if (loggedInView) {
if (isGuest) {
loggedInView.classList.add('hidden');
if (loginView) loginView.classList.remove('hidden');
if (registerView) registerView.classList.add('hidden');
} else {
loggedInView.classList.remove('hidden');
if (loginView) loginView.classList.add('hidden');
if (registerView) registerView.classList.add('hidden');
if (userTitle) userTitle.textContent = user;
if (statCoins) statCoins.textContent = (uData.coins || (this.economy ? this.economy.totalCoins : 0)).toLocaleString('ru-RU');
if (statLevel) statLevel.textContent = `Уровень ${uData.level || this.currentLevel || 1}`;
if (statScore) statScore.textContent = (uData.highScore || this.highScore || 0).toLocaleString('ru-RU');
if (statMaterials) {
const mats = uData.materials || (this.workshop ? this.workshop.materials : {});
const totalMats = (mats.bioHumus || 0) + (mats.scrapMetal || 0) + (mats.sunEssence || 0) + (mats.crystals || 0);
statMaterials.textContent = `${totalMats} шт.`;
}
}
}
}
}
openMainMenu() {
this.isRunning = false;
if (window.PvZAudio) window.PvZAudio.stopMusic();
const startModal = document.getElementById('start-modal');
if (startModal) startModal.classList.remove('hidden');
// 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 => {
const el = document.getElementById(id);
if (el) el.classList.add('hidden');
});
// Highlight the mode button that matches currentMode
const modeMap = {
adventure: 'btn-mode-adv',
survival: 'btn-mode-surv',
bowling: 'btn-mode-bowl',
izombie: 'btn-mode-izombie',
roguelike: 'btn-mode-rogue',
boss: 'btn-mode-boss',
bossrush: 'btn-mode-bossrush'
};
const activeBtnId = modeMap[this.currentMode] || 'btn-mode-adv';
this.highlightModeBtn(activeBtnId);
this.updateRecordDisplay();
this.updateProfileUI();
}
updateRecordDisplay() {
const elRecord = document.getElementById('start-record');
if (elRecord) {
const lvlLabel = (window.CampaignSystem && this.currentMode === 'adventure')
? window.CampaignSystem.formatLevel(this.currentLevel).fullLabel
: this.currentLevel;
elRecord.innerHTML = `🏆 Рекорд: <strong>${this.highScore}</strong> очков &nbsp;|&nbsp; 💰 Монеты: <strong>${this.economy.totalCoins}</strong> &nbsp;|&nbsp; Уровень: <strong>${lvlLabel}</strong>`;
}
}
addScore(pts) {
this.score += pts;
if (this.score > this.highScore) {
this.highScore = this.score;
this.saveUserData();
}
}
startLevel(level = 1, mode = 'adventure') {
this.currentLevel = level;
this.currentMode = mode;
// Pre-set economy mode flags BEFORE reset() so economy.reset() computes
// the correct baseSun (avoids stale isConveyor/isIZombie from previous game)
this.economy.isConveyor = (mode === 'bowling' || mode === 'boss' || mode === 'bossrush');
this.economy.isIZombie = (mode === 'izombie');
this.economy.isNight = (mode === 'izombie');
this.reset();
this.wave.setLevel(level, mode);
this.grid.initBiome(this.wave.biome);
if (mode === 'bowling') {
this.economy.setConveyorDeck();
this.hasRakeArmed = false;
} else if (mode === 'izombie') {
this.economy.setIZombieDeck();
this.grid.lawnmowers = [];
this.hasRakeArmed = false;
this.setupIZombieLawn(); // place lawn plants for I, Zombie mode
} else if (mode === 'boss' || mode === 'bossrush') {
this.economy.setBossDeck();
if (this.boss) this.boss.reset();
this.hasRakeArmed = false;
} else if (mode === 'roguelike') {
this.economy.isConveyor = false;
this.economy.isIZombie = false;
if (!this.economy.cards || this.economy.cards.length === 0 || (this.roguelike && this.roguelike.currentFloor === 1) || this.economy.cards.some(c => c.id.startsWith('z_') || c.id.startsWith('bowling_'))) {
this.economy.cards = this.economy.allCards
.filter(c => ['sunflower', 'peashooter'].includes(c.id))
.map(c => ({ ...c, timer: 0 }));
}
this.hasRakeArmed = this.economy.upgrades.hasRake;
this.rakeRow = Math.floor(Math.random() * this.grid.rows);
this.rakeCol = 7;
} else {
this.economy.setDeckForLevel(level, this.wave.biome);
this.hasRakeArmed = this.economy.upgrades.hasRake;
this.rakeRow = Math.floor(Math.random() * this.grid.rows);
this.rakeCol = 7;
}
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() {
this.plants = [];
this.izombieSetupDone = true;
// 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);
}
// 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);
}
init() {
let startLvl = 1;
if (this.currentMode === 'adventure') {
startLvl = this.currentLevel || 1;
} else if (this.currentMode === 'roguelike') {
startLvl = this.roguelike ? this.roguelike.currentFloor : 1;
}
this.startLevel(startLvl, this.currentMode);
if (!this.loopStarted) {
this.loopStarted = true;
requestAnimationFrame(this.loop.bind(this));
}
}
reset() {
this.grid.reset();
this.economy.reset();
this.wave.reset();
this.combat.reset();
this.plants = [];
this.zombies = [];
this.projectiles = [];
this.score = 0;
this.zombiesKilled = 0;
this.isGameOver = false;
this.isVictory = false;
this.hoverCell = null;
this.izombieSetupDone = false; // ensure clean re-init for I, Zombie mode
document.getElementById('game-over-modal').classList.add('hidden');
document.getElementById('victory-modal').classList.add('hidden');
}
spawnSun(x, y, value = 25, isFalling = false, targetY = null) {
let finalValue = value;
if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_sun_resonance') && !isFalling) {
finalValue += 15;
}
this.economy.spawnSun(x, y, finalValue, isFalling, targetY);
}
spawnCoin(x, y, type = 'silver') {
this.economy.spawnCoin(x, y, type);
}
spawnZombie(row, type = 'normal') {
const zombie = new window.Zombie(row, type);
const bounds = this.grid.getCellBounds(0, row);
zombie.y = bounds.centerY - 5;
this.zombies.push(zombie);
}
explodeArea(centerCol, centerRow, radiusTiles = 3) {
const half = Math.floor(radiusTiles / 2);
const bounds = this.grid.getCellBounds(centerCol, centerRow);
this.combat.createExplosionParticles(bounds.centerX, bounds.centerY);
for (const z of this.zombies) {
if (z.toRemove || z.health <= 0) continue;
const rowDiff = Math.abs(z.row - centerRow);
const colEstimated = (z.x - this.grid.startX) / this.grid.cellW;
const colDiff = Math.abs(colEstimated - centerCol);
if (rowDiff <= half && colDiff <= half + 0.5) {
z.takeDamage(1800);
if (z.health <= 0) {
this.addScore(250);
this.zombiesKilled++;
if (Math.random() < 0.4) this.spawnCoin(z.x, z.y, 'gold');
}
}
}
}
triggerGameOver(reason = 'Зомби прорвали оборону газона. Попробуйте снова!') {
if (this.isGameOver) return;
this.isGameOver = true;
this.isRunning = false;
if (window.PvZAudio) {
window.PvZAudio.stopMusic();
window.PvZAudio.playLose();
}
if (this.leaderboards) {
if (this.currentMode === 'survival') {
this.leaderboards.submitScore('survival', this.wave.survivalFlags || 0, `${this.wave.survivalFlags || 0} Флагов`);
} else if (this.currentMode === 'roguelike') {
const flr = this.roguelike ? this.roguelike.currentFloor : 1;
this.leaderboards.submitScore('roguelike', flr, `${flr} Этаж`);
} else {
this.leaderboards.submitScore(this.currentMode, this.score, `${this.score} очков`);
}
}
this.saveUserData();
if (window.PvZAuth && window.PvZAuth.flushSave) window.PvZAuth.flushSave();
const descEl = document.getElementById('game-over-desc');
if (descEl) descEl.textContent = reason;
const statsEl = document.getElementById('game-over-stats');
if (statsEl) {
statsEl.innerHTML = `Счёт: <strong>${this.score}</strong> | Зомби: <strong>${this.zombiesKilled}</strong><br>Рекорд: <strong>${this.highScore}</strong> | Монеты: <strong>${this.economy.totalCoins}</strong>`;
}
document.getElementById('game-over-modal').classList.remove('hidden');
}
triggerVictory() {
if (this.isVictory) return;
this.isVictory = true;
this.isRunning = false;
this.addScore(1000);
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.achievements) this.achievements.trigger('clean_lawn');
}
if (this.currentMode === 'izombie' && this.achievements) {
this.achievements.trigger('izombie_win');
}
if (this.leaderboards) {
if (this.currentMode === 'bossrush') {
this.leaderboards.submitScore('bossrush', 3, 'Boss Rush пройден! (3 волны)');
this.economy.addCoins(400); // 100 + 400 = 500 bonus
if (this.workshop) {
this.workshop.materials.crystals += 2;
}
} else if (this.currentMode === 'boss') {
this.leaderboards.submitScore('boss', 1, 'Победа над Зомбоссом');
} else if (this.currentMode === 'adventure') {
this.leaderboards.submitScore('adventure', this.currentLevel, `Уровень ${this.currentLevel}`);
} else if (this.currentMode === 'roguelike') {
const flr = this.roguelike ? this.roguelike.currentFloor : 1;
this.leaderboards.submitScore('roguelike', flr, `${flr} Этажей`);
} else {
this.leaderboards.submitScore(this.currentMode, this.score, `${this.score} очков`);
}
}
// Save level progression
let levelReward = 100;
if (this.currentMode === 'adventure') {
const campLevel = window.CampaignSystem ? window.CampaignSystem.getLevel(this.currentLevel) : null;
if (campLevel && campLevel.reward) {
levelReward = campLevel.reward;
if (levelReward > 100) {
this.economy.addCoins(levelReward - 100);
}
}
if (this.currentLevel >= this.maxUnlockedLevel && this.currentLevel < 25) {
this.maxUnlockedLevel = this.currentLevel + 1;
}
if (this.currentLevel < 25) {
this.currentLevel++;
}
}
this.saveUserData();
if (window.PvZAuth && window.PvZAuth.flushSave) window.PvZAuth.flushSave();
if (this.currentMode === 'roguelike') {
if (window.PvZAudio) {
window.PvZAudio.stopMusic();
window.PvZAudio.playWin();
}
this.showDraftModal();
return;
}
if (window.PvZAudio) {
window.PvZAudio.stopMusic();
window.PvZAudio.playWin();
}
const statsEl = document.getElementById('victory-stats');
if (statsEl) {
if (this.currentMode === 'izombie') {
statsEl.innerHTML = `Победа! Все подсолнухи съедены! Мозги захвачены!<br>Награда: <strong>+100 💰 монет</strong> (Всего: ${this.economy.totalCoins})`;
} else if (this.currentMode === 'bossrush') {
statsEl.innerHTML = `Триумф! Режим Boss Rush успешно завершён!<br>Награда: <strong>+500 💰 монет</strong> &bull; <strong>+2 💎 кристалла</strong> (Всего: ${this.economy.totalCoins})`;
} else if (this.currentMode === 'adventure' && window.CampaignSystem) {
const nextInfo = window.CampaignSystem.formatLevel(this.currentLevel);
statsEl.innerHTML = `Победа! Финальный счёт: <strong>${this.score}</strong> (+1000 очков)<br>Награда за этап: <strong>+${levelReward} 💰 монет</strong> (Всего: ${this.economy.totalCoins})<br>Следующий этап: <strong>${nextInfo.fullLabel}</strong>`;
} else {
statsEl.innerHTML = `Победа! Финальный счёт: <strong>${this.score}</strong> (+1000 очков)<br>Награда: <strong>+100 💰 монет</strong> (Всего: ${this.economy.totalCoins})<br>Уровень разблокирован: <strong>${this.currentLevel}</strong>`;
}
}
document.getElementById('victory-modal').classList.remove('hidden');
}
triggerHaptic(ms = 15) {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
try { navigator.vibrate(ms); } catch (_) {}
}
}
getEventPos(e) {
const rect = this.canvas.getBoundingClientRect();
let clientX, clientY;
if (e.touches && e.touches.length > 0) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else if (e.changedTouches && e.changedTouches.length > 0) {
clientX = e.changedTouches[0].clientX;
clientY = e.changedTouches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const scaleX = this.logicalWidth / rect.width;
const scaleY = this.logicalHeight / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
checkTouchCollect(pos) {
for (let i = this.economy.suns.length - 1; i >= 0; i--) {
const s = this.economy.suns[i];
if (!s.collecting && Math.hypot(pos.x - s.x, pos.y - s.y) <= s.radius + 28) {
s.collecting = true;
this.triggerHaptic(12);
if (window.PvZAudio) window.PvZAudio.playSun();
}
}
for (let i = this.economy.coins.length - 1; i >= 0; i--) {
const c = this.economy.coins[i];
if (!c.collecting && Math.hypot(pos.x - c.x, pos.y - c.y) <= c.radius + 28) {
c.collecting = true;
this.triggerHaptic(15);
if (window.PvZAudio) window.PvZAudio.playCoin();
}
}
}
bindEvents() {
this.canvas.addEventListener('mousemove', (e) => {
const pos = this.getEventPos(e);
this.hoverCell = this.grid.getCellFromCoords(pos.x, pos.y);
});
this.canvas.addEventListener('mouseleave', () => {
this.hoverCell = null;
});
this.canvas.addEventListener('click', (e) => {
if (!this.isRunning) return;
const pos = this.getEventPos(e);
const econResult = this.economy.handleClick(pos.x, pos.y);
if (econResult.handled) {
this.triggerHaptic(15);
return;
}
const cell = this.grid.getCellFromCoords(pos.x, pos.y);
if (cell) {
this.handleCellClick(cell.col, cell.row);
this.triggerHaptic(25);
}
});
// Mobile touch with Drag & Drop and continuous swipe-collect
this.canvas.addEventListener('touchstart', (e) => {
if (!this.isRunning) return;
const pos = this.getEventPos(e);
this.checkTouchCollect(pos);
const econResult = this.economy.handleClick(pos.x, pos.y);
if (econResult.handled) {
this.triggerHaptic(15);
this.dragCard = this.economy.selectedCard;
return;
}
const cell = this.grid.getCellFromCoords(pos.x, pos.y);
if (cell) {
this.hoverCell = cell;
this.handleCellClick(cell.col, cell.row);
this.triggerHaptic(25);
}
}, { passive: false });
this.canvas.addEventListener('touchmove', (e) => {
if (!this.isRunning) return;
e.preventDefault();
const pos = this.getEventPos(e);
this.checkTouchCollect(pos);
this.hoverCell = this.grid.getCellFromCoords(pos.x, pos.y);
}, { passive: false });
this.canvas.addEventListener('touchend', (e) => {
if (!this.isRunning) return;
const pos = this.getEventPos(e);
if (this.dragCard) {
const cell = this.grid.getCellFromCoords(pos.x, pos.y);
if (cell && this.economy.selectedCard === this.dragCard) {
this.handleCellClick(cell.col, cell.row);
this.triggerHaptic(30);
}
this.dragCard = null;
}
this.hoverCell = null;
});
}
bindUI() {
// Mobile / Main Drawer Handlers
const mobileDrawer = document.getElementById('mobile-drawer');
const drawerBackdrop = document.getElementById('drawer-backdrop');
const btnMainMenuDrawer = document.getElementById('btn-main-menu-drawer');
const btnDrawerOpen = document.getElementById('btn-mobile-drawer');
const btnDrawerClose = document.getElementById('btn-drawer-close');
const openDrawer = () => {
if (mobileDrawer) mobileDrawer.classList.remove('hidden');
if (drawerBackdrop) drawerBackdrop.classList.remove('hidden');
this.triggerHaptic(10);
};
const closeDrawer = () => {
if (mobileDrawer) mobileDrawer.classList.add('hidden');
if (drawerBackdrop) drawerBackdrop.classList.add('hidden');
};
if (btnMainMenuDrawer) btnMainMenuDrawer.addEventListener('click', openDrawer);
if (btnDrawerOpen) btnDrawerOpen.addEventListener('click', openDrawer);
if (btnDrawerClose) btnDrawerClose.addEventListener('click', closeDrawer);
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
// Main Screen action from Drawer
const btnDrawerMain = document.getElementById('btn-drawer-main-screen');
if (btnDrawerMain) {
btnDrawerMain.addEventListener('click', () => {
closeDrawer();
this.openMainMenu();
});
}
// Theme & Music actions from Drawer
const btnDrawerTheme = document.getElementById('btn-drawer-theme');
const drawerThemeLabel = document.getElementById('drawer-theme-label');
if (btnDrawerTheme) {
btnDrawerTheme.addEventListener('click', () => {
const themeNames = { classic: 'Классика', cyberpunk: 'Киберпанк', retro8bit: 'Ретро 8-bit', winter: 'Зима' };
const keys = Object.keys(themeNames);
const nextIndex = (keys.indexOf(this.currentTheme) + 1) % keys.length;
this.applyTheme(keys[nextIndex]);
if (drawerThemeLabel) drawerThemeLabel.textContent = themeNames[this.currentTheme];
this.triggerHaptic(15);
});
}
const btnDrawerMusic = document.getElementById('btn-drawer-music');
const drawerMusicLabel = document.getElementById('drawer-music-label');
if (btnDrawerMusic) {
btnDrawerMusic.addEventListener('click', () => {
const isPlaying = window.PvZAudio ? window.PvZAudio.toggleMusic() : false;
if (drawerMusicLabel) drawerMusicLabel.textContent = isPlaying ? 'Вкл' : 'Выкл';
this.triggerHaptic(15);
});
}
// Drawer Items delegation to corresponding modal open triggers
const drawerActions = [
{ id: 'btn-drawer-campaign', target: 'btn-campaign-open' },
{ id: 'btn-drawer-workshop', target: 'btn-workshop' },
{ id: 'btn-drawer-zengarden', target: 'btn-zengarden' },
{ id: 'btn-drawer-almanac', target: 'btn-almanac' },
{ id: 'btn-drawer-achievements', target: 'btn-achievements' },
{ id: 'btn-drawer-tree', target: 'btn-tree' },
{ id: 'btn-drawer-leaderboard', target: 'btn-leaderboard' },
{ id: 'btn-drawer-bugreport', target: 'btn-bugreport' }
];
drawerActions.forEach(action => {
const btn = document.getElementById(action.id);
if (btn) {
btn.addEventListener('click', () => {
closeDrawer();
const targetBtn = document.getElementById(action.target);
if (targetBtn) targetBtn.click();
});
}
});
// Menu Button (Top Nav)
const btnMenu = document.getElementById('btn-menu');
if (btnMenu) {
btnMenu.addEventListener('click', () => {
this.openMainMenu();
});
}
// Secondary Menu Buttons in Game Over / Victory
const btnMenuLose = document.getElementById('btn-menu-lose');
if (btnMenuLose) {
btnMenuLose.addEventListener('click', () => {
document.getElementById('game-over-modal').classList.add('hidden');
this.openMainMenu();
});
}
const btnMenuWin = document.getElementById('btn-menu-win');
if (btnMenuWin) {
btnMenuWin.addEventListener('click', () => {
document.getElementById('victory-modal').classList.add('hidden');
this.openMainMenu();
});
}
// Speed
const btnSpeed = document.getElementById('btn-speed');
if (btnSpeed) {
btnSpeed.addEventListener('click', () => {
this.gameSpeed = this.gameSpeed === 1.0 ? 2.0 : 1.0;
btnSpeed.textContent = this.gameSpeed === 1.0 ? '⚡ 1x' : '⚡ 2x';
});
}
// Pause Button
const btnPause = document.getElementById('btn-pause');
if (btnPause) {
btnPause.addEventListener('click', () => {
this.togglePause();
});
}
// Global Pause Hotkeys (Space, KeyP)
window.addEventListener('keydown', (e) => {
// Do not intercept hotkeys if user is typing in an input or textarea
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
if (e.code === 'Space' || e.code === 'KeyP') {
e.preventDefault();
if (this.isRunning) {
this.togglePause();
}
}
});
// Music
const btnMusic = document.getElementById('btn-music');
if (btnMusic) {
btnMusic.addEventListener('click', () => {
const isPlaying = window.PvZAudio.toggleMusic();
btnMusic.textContent = isPlaying ? '🎵 Музыка: Вкл' : '🎵 Музыка: Выкл';
});
}
// Audio SFX
const btnMute = document.getElementById('btn-mute');
if (btnMute) {
btnMute.addEventListener('click', () => {
const isMuted = window.PvZAudio.toggleMute();
btnMute.textContent = isMuted ? '🔇 Вкл звук' : '🔊 Звук';
});
}
// Restart Lose
const btnRestartLose = document.getElementById('btn-restart-lose');
if (btnRestartLose) {
btnRestartLose.addEventListener('click', () => {
this.startLevel(this.currentLevel, this.currentMode);
});
}
// Next Level / Win
const btnRestartWin = document.getElementById('btn-restart-win');
if (btnRestartWin) {
btnRestartWin.addEventListener('click', () => {
this.startLevel(this.currentLevel, this.currentMode);
});
}
// Auth & Profile Modal Handlers
const authModal = document.getElementById('auth-modal');
const btnAuthOpen = document.getElementById('btn-auth-open');
const btnAuthClose = document.getElementById('btn-auth-close');
const btnAuthCloseProfile = document.getElementById('btn-auth-close-profile');
const btnAuthCloseReg = document.getElementById('btn-auth-close-reg');
const loginView = document.getElementById('auth-login-view') || document.getElementById('auth-guest-view');
const registerView = document.getElementById('auth-register-view');
const btnAuthToRegister = document.getElementById('btn-auth-to-register');
const btnAuthToLogin = document.getElementById('btn-auth-to-login');
const btnAuthSubmit = document.getElementById('btn-auth-submit'); // Войти
const btnAuthDoRegister = document.getElementById('btn-auth-do-register'); // Зарегистрироваться
const btnAuthGuest = document.getElementById('btn-auth-guest'); // Остаться гостем
const btnAuthLogout = document.getElementById('btn-auth-logout'); // Выйти
const btnAuthLogoutNav = document.getElementById('btn-auth-logout-nav');
const authUser = document.getElementById('auth-username');
const authPass = document.getElementById('auth-password');
const authError = document.getElementById('auth-error');
const authRegUser = document.getElementById('auth-reg-username');
const authRegPass = document.getElementById('auth-reg-password');
const authRegError = document.getElementById('auth-reg-error');
const resetAuthForms = () => {
if (authUser) authUser.value = '';
if (authPass) authPass.value = '';
if (authError) { authError.textContent = ''; authError.classList.add('hidden'); }
if (authRegUser) authRegUser.value = '';
if (authRegPass) authRegPass.value = '';
if (authRegError) { authRegError.textContent = ''; authRegError.classList.add('hidden'); }
};
const handleLogout = () => {
if (window.PvZAuth) {
window.PvZAuth.logout();
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
this.updateShopUI();
}
if (authModal) authModal.classList.add('hidden');
resetAuthForms();
if (window.PvZAudio) window.PvZAudio.playCoin();
};
if (btnAuthLogoutNav) {
btnAuthLogoutNav.addEventListener('click', handleLogout);
}
if (btnAuthLogout) {
btnAuthLogout.addEventListener('click', handleLogout);
}
if (btnAuthOpen && authModal) {
btnAuthOpen.addEventListener('click', () => {
resetAuthForms();
this.updateProfileUI();
authModal.classList.remove('hidden');
if (authUser && window.PvZAuth && window.PvZAuth.isGuest()) {
setTimeout(() => authUser.focus(), 50);
}
});
}
// Modal Close buttons
if (btnAuthClose && authModal) {
btnAuthClose.addEventListener('click', () => {
authModal.classList.add('hidden');
resetAuthForms();
});
}
if (btnAuthCloseProfile && authModal) {
btnAuthCloseProfile.addEventListener('click', () => {
authModal.classList.add('hidden');
});
}
if (btnAuthCloseReg && authModal) {
btnAuthCloseReg.addEventListener('click', () => {
authModal.classList.add('hidden');
resetAuthForms();
});
}
// Switch between Login and Register views
if (btnAuthToRegister && loginView && registerView) {
btnAuthToRegister.addEventListener('click', () => {
resetAuthForms();
loginView.classList.add('hidden');
registerView.classList.remove('hidden');
if (authRegUser) setTimeout(() => authRegUser.focus(), 50);
});
}
if (btnAuthToLogin && loginView && registerView) {
btnAuthToLogin.addEventListener('click', () => {
resetAuthForms();
registerView.classList.add('hidden');
loginView.classList.remove('hidden');
if (authUser) setTimeout(() => authUser.focus(), 50);
});
}
// Stay as Guest button
if (btnAuthGuest && authModal) {
btnAuthGuest.addEventListener('click', () => {
if (window.PvZAuth) window.PvZAuth.loginAsGuest();
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
authModal.classList.add('hidden');
resetAuthForms();
if (window.PvZAudio) window.PvZAudio.playButtonClick();
});
}
// Login Action (Войти)
if (btnAuthSubmit && authModal) {
btnAuthSubmit.addEventListener('click', async () => {
const u = authUser ? authUser.value.trim() : '';
const p = authPass ? authPass.value : '';
if (!u) {
if (authError) { authError.textContent = 'Введите логин'; authError.classList.remove('hidden'); }
return;
}
if (!p) {
if (authError) { authError.textContent = 'Введите пароль'; authError.classList.remove('hidden'); }
return;
}
if (authError) authError.classList.add('hidden');
if (window.PvZAuth) {
btnAuthSubmit.textContent = '⏳ Вход...';
btnAuthSubmit.disabled = true;
const res = await window.PvZAuth.login(u, p);
btnAuthSubmit.textContent = '🔑 Войти';
btnAuthSubmit.disabled = false;
if (res && res.success) {
authModal.classList.add('hidden');
resetAuthForms();
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
this.updateShopUI();
if (window.PvZAudio) window.PvZAudio.playCoin();
} else {
if (authError) {
authError.textContent = (res && res.message) ? res.message : 'Неверный логин или пароль';
authError.classList.remove('hidden');
}
}
}
});
}
// Register Action (Зарегистрироваться)
if (btnAuthDoRegister && authModal) {
btnAuthDoRegister.addEventListener('click', async () => {
const u = authRegUser ? authRegUser.value.trim() : '';
const p = authRegPass ? authRegPass.value : '';
if (!u) {
if (authRegError) { authRegError.textContent = 'Введите логин для регистрации'; authRegError.classList.remove('hidden'); }
return;
}
if (u.length < 3) {
if (authRegError) { authRegError.textContent = 'Логин должен содержать минимум 3 символа'; authRegError.classList.remove('hidden'); }
return;
}
if (!p || p.length < 4) {
if (authRegError) { authRegError.textContent = 'Пароль должен содержать минимум 4 символа'; authRegError.classList.remove('hidden'); }
return;
}
if (authRegError) authRegError.classList.add('hidden');
if (window.PvZAuth) {
btnAuthDoRegister.textContent = '⏳ Регистрация...';
btnAuthDoRegister.disabled = true;
const res = await window.PvZAuth.register(u, p);
btnAuthDoRegister.textContent = '✨ Зарегистрироваться';
btnAuthDoRegister.disabled = false;
if (res && res.success) {
authModal.classList.add('hidden');
resetAuthForms();
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
this.updateShopUI();
if (window.PvZAudio) window.PvZAudio.playCoin();
} else {
if (authRegError) {
authRegError.textContent = (res && res.message) ? res.message : 'Ошибка при регистрации';
authRegError.classList.remove('hidden');
}
}
}
});
}
// Shop Button
const btnShop = document.getElementById('btn-shop');
const shopModal = document.getElementById('shop-modal');
const btnCloseShop = document.getElementById('btn-close-shop');
if (btnShop && shopModal) {
btnShop.addEventListener('click', () => {
this.updateShopUI();
shopModal.classList.remove('hidden');
});
}
if (btnCloseShop && shopModal) {
btnCloseShop.addEventListener('click', () => {
shopModal.classList.add('hidden');
this.updateRecordDisplay();
});
}
// Shop Item Purchases
const btnBuySlot = document.getElementById('btn-buy-slot');
if (btnBuySlot) {
btnBuySlot.addEventListener('click', () => {
if (this.economy.buyUpgrade('extraSlot')) {
if (window.PvZAudio) window.PvZAudio.playWin();
this.saveUserData();
this.updateShopUI();
}
});
}
const btnBuyRake = document.getElementById('btn-buy-rake');
if (btnBuyRake) {
btnBuyRake.addEventListener('click', () => {
if (this.economy.buyUpgrade('rake')) {
if (window.PvZAudio) window.PvZAudio.playWin();
this.saveUserData();
this.updateShopUI();
}
});
}
// Workshop Modal Handlers
const workshopModal = document.getElementById('workshop-modal');
const btnWorkshopNav = document.getElementById('btn-workshop');
const btnWorkshopStart = document.getElementById('btn-workshop-start');
const btnCloseWorkshop = document.getElementById('btn-close-workshop');
const tabPlants = document.getElementById('tab-plants');
const tabPerks = document.getElementById('tab-perks');
const contentPlants = document.getElementById('ws-content-plants');
const contentPerks = document.getElementById('ws-content-perks');
const openWorkshop = () => {
this.updateWorkshopUI();
if (workshopModal) workshopModal.classList.remove('hidden');
};
if (btnWorkshopNav) btnWorkshopNav.addEventListener('click', openWorkshop);
if (btnWorkshopStart) btnWorkshopStart.addEventListener('click', openWorkshop);
if (btnCloseWorkshop && workshopModal) {
btnCloseWorkshop.addEventListener('click', () => {
workshopModal.classList.add('hidden');
this.updateRecordDisplay();
});
}
if (tabPlants && tabPerks && contentPlants && contentPerks) {
tabPlants.addEventListener('click', () => {
tabPlants.classList.add('active');
tabPerks.classList.remove('active');
contentPlants.classList.remove('hidden');
contentPerks.classList.add('hidden');
});
tabPerks.addEventListener('click', () => {
tabPerks.classList.add('active');
tabPlants.classList.remove('active');
contentPerks.classList.remove('hidden');
contentPlants.classList.add('hidden');
});
}
// Zen Garden Modal Handlers
const zenModal = document.getElementById('zengarden-modal');
const btnZenNav = document.getElementById('btn-zengarden');
const btnZenStart = document.getElementById('btn-zengarden-start');
const btnCloseZen = document.getElementById('btn-close-zengarden');
const openZenGarden = () => {
if (this.zengarden) {
this.zengarden.calculateOfflineEarnings();
}
this.updateZenGardenUI();
if (zenModal) zenModal.classList.remove('hidden');
};
if (btnZenNav) btnZenNav.addEventListener('click', openZenGarden);
if (btnZenStart) btnZenStart.addEventListener('click', openZenGarden);
if (btnCloseZen && zenModal) {
btnCloseZen.addEventListener('click', () => {
zenModal.classList.add('hidden');
this.updateRecordDisplay();
});
}
// Suburban Almanac Modal Handlers
const almanacModal = document.getElementById('almanac-modal');
const btnAlmanacNav = document.getElementById('btn-almanac');
const btnAlmanacStart = document.getElementById('btn-almanac-start');
const btnCloseAlmanac = document.getElementById('btn-close-almanac');
const openAlmanac = () => {
this.renderAlmanacUI();
if (almanacModal) almanacModal.classList.remove('hidden');
};
if (btnAlmanacNav) btnAlmanacNav.addEventListener('click', openAlmanac);
if (btnAlmanacStart) btnAlmanacStart.addEventListener('click', openAlmanac);
if (btnCloseAlmanac && almanacModal) {
btnCloseAlmanac.addEventListener('click', () => {
almanacModal.classList.add('hidden');
});
}
// Achievements Modal Handlers
const achModal = document.getElementById('achievements-modal');
const btnAchNav = document.getElementById('btn-achievements');
const btnAchStart = document.getElementById('btn-achievements-start');
const btnCloseAch = document.getElementById('btn-close-achievements');
const openAchievements = () => {
this.renderAchievementsUI();
if (achModal) achModal.classList.remove('hidden');
};
if (btnAchNav) btnAchNav.addEventListener('click', openAchievements);
if (btnAchStart) btnAchStart.addEventListener('click', openAchievements);
if (btnCloseAch && achModal) {
btnCloseAch.addEventListener('click', () => {
achModal.classList.add('hidden');
});
}
// Bug Reporting Modal Handlers
const bugModal = document.getElementById('bugreport-modal');
const btnBugNav = document.getElementById('btn-bugreport');
const btnBugStart = document.getElementById('btn-bugreport-start');
const btnCloseBug = document.getElementById('btn-bug-close');
const openBugReport = () => {
if (this.authSystem && this.authSystem.currentUser) {
const nameInput = document.getElementById('bug-input-name');
if (nameInput && !nameInput.value) {
nameInput.value = this.authSystem.currentUser.username;
}
}
if (window.PvZFeedback) {
window.PvZFeedback.fetchReports();
}
if (bugModal) bugModal.classList.remove('hidden');
};
if (btnBugNav) btnBugNav.addEventListener('click', openBugReport);
if (btnBugStart) btnBugStart.addEventListener('click', openBugReport);
if (btnCloseBug && bugModal) {
btnCloseBug.addEventListener('click', () => {
bugModal.classList.add('hidden');
});
}
// Initialize Zen Garden & Almanac & Feedback DOM bindings
this.initZenGarden();
this.initAlmanac();
this.initFeedbackUI();
// Mode Buttons on Start Screen
const btnModeAdv = document.getElementById('btn-mode-adv');
if (btnModeAdv) {
btnModeAdv.addEventListener('click', () => {
this.currentMode = 'adventure';
this.highlightModeBtn('btn-mode-adv');
this.updateRecordDisplay();
});
}
const btnModeSurv = document.getElementById('btn-mode-surv');
if (btnModeSurv) {
btnModeSurv.addEventListener('click', () => {
this.currentMode = 'survival';
this.highlightModeBtn('btn-mode-surv');
this.updateRecordDisplay();
});
}
const btnModeBowl = document.getElementById('btn-mode-bowl');
if (btnModeBowl) {
btnModeBowl.addEventListener('click', () => {
this.currentMode = 'bowling';
this.highlightModeBtn('btn-mode-bowl');
this.updateRecordDisplay();
});
}
const btnModeIzombie = document.getElementById('btn-mode-izombie');
if (btnModeIzombie) {
btnModeIzombie.addEventListener('click', () => {
this.currentMode = 'izombie';
this.highlightModeBtn('btn-mode-izombie');
this.updateRecordDisplay();
});
}
const btnModeRogue = document.getElementById('btn-mode-rogue');
if (btnModeRogue) {
btnModeRogue.addEventListener('click', () => {
this.currentMode = 'roguelike';
if (this.roguelike) this.roguelike.reset();
this.highlightModeBtn('btn-mode-rogue');
this.updateRecordDisplay();
});
}
const btnModeBoss = document.getElementById('btn-mode-boss');
if (btnModeBoss) {
btnModeBoss.addEventListener('click', () => {
this.currentMode = 'boss';
this.highlightModeBtn('btn-mode-boss');
this.updateRecordDisplay();
});
}
const btnModeBossRush = document.getElementById('btn-mode-bossrush');
if (btnModeBossRush) {
btnModeBossRush.addEventListener('click', () => {
this.currentMode = 'bossrush';
this.highlightModeBtn('btn-mode-bossrush');
this.updateRecordDisplay();
});
}
// Theme Switcher Button
const btnTheme = document.getElementById('btn-theme');
if (btnTheme) {
btnTheme.addEventListener('click', () => {
this.cycleTheme();
});
}
// Tree of Wisdom Modal Handlers
const treeModal = document.getElementById('tree-modal');
const btnTreeNav = document.getElementById('btn-tree');
const btnTreeStart = document.getElementById('btn-tree-start');
const btnCloseTree = document.getElementById('btn-tree-close');
const btnTreeFeed = document.getElementById('btn-tree-feed');
const openTree = () => {
this.renderTreeUI();
if (treeModal) treeModal.classList.remove('hidden');
};
if (btnTreeNav) btnTreeNav.addEventListener('click', openTree);
if (btnTreeStart) btnTreeStart.addEventListener('click', openTree);
if (btnCloseTree && treeModal) {
btnCloseTree.addEventListener('click', () => {
treeModal.classList.add('hidden');
});
}
if (btnTreeFeed) {
btnTreeFeed.addEventListener('click', () => {
if (this.tree && this.tree.feedFertilizer()) {
this.renderTreeUI();
this.updateRecordDisplay();
this.updateShopUI();
}
});
}
// Leaderboards Modal Handlers
const lbModal = document.getElementById('leaderboard-modal');
const btnLbNav = document.getElementById('btn-leaderboard');
const btnLbStart = document.getElementById('btn-leaderboard-start');
const btnCloseLb = document.getElementById('btn-leaderboard-close');
const openLeaderboard = (mode = 'survival') => {
this.renderLeaderboardUI(mode);
if (lbModal) lbModal.classList.remove('hidden');
};
if (btnLbNav) btnLbNav.addEventListener('click', () => openLeaderboard('survival'));
if (btnLbStart) btnLbStart.addEventListener('click', () => openLeaderboard('survival'));
if (btnCloseLb && lbModal) {
btnCloseLb.addEventListener('click', () => {
lbModal.classList.add('hidden');
});
}
const lbTabs = [
{ id: 'tab-lb-survival', mode: 'survival' },
{ id: 'tab-lb-rogue', mode: 'roguelike' },
{ id: 'tab-lb-adv', mode: 'adventure' },
{ id: 'tab-lb-izombie', mode: 'izombie' },
{ id: 'tab-lb-bowl', mode: 'bowling' },
{ id: 'tab-lb-boss', mode: 'boss' },
{ id: 'tab-lb-bossrush', mode: 'bossrush' }
];
lbTabs.forEach(t => {
const el = document.getElementById(t.id);
if (el) {
el.addEventListener('click', () => {
lbTabs.forEach(other => {
const oEl = document.getElementById(other.id);
if (oEl) oEl.classList.remove('active');
});
el.classList.add('active');
this.renderLeaderboardUI(t.mode);
});
}
});
// Campaign Map Modal Handlers
const campaignModal = document.getElementById('campaign-modal');
const btnCampaignNav = document.getElementById('btn-campaign-open');
const btnCampaignStart = document.getElementById('btn-campaign-select');
const btnCloseCampaign = document.getElementById('btn-campaign-close');
if (btnCampaignNav) btnCampaignNav.addEventListener('click', () => this.openCampaignModal());
if (btnCampaignStart) btnCampaignStart.addEventListener('click', () => this.openCampaignModal());
if (btnCloseCampaign && campaignModal) {
btnCloseCampaign.addEventListener('click', () => {
campaignModal.classList.add('hidden');
this.updateRecordDisplay();
});
}
for (let w = 1; w <= 5; w++) {
const tab = document.getElementById(`tab-world-${w}`);
if (tab) {
tab.addEventListener('click', () => {
this.renderCampaignMapUI(w);
});
}
}
}
openCampaignModal(worldId = null) {
if (!worldId) {
if (window.CampaignSystem) {
const currentItem = window.CampaignSystem.getLevel(this.currentLevel);
worldId = currentItem ? currentItem.world : 1;
} else {
worldId = 1;
}
}
this.renderCampaignMapUI(worldId);
const modal = document.getElementById('campaign-modal');
if (modal) modal.classList.remove('hidden');
}
renderCampaignMapUI(activeWorldId = 1) {
const gridEl = document.getElementById('campaign-levels-grid');
if (!gridEl || !window.CampaignSystem) return;
// Update active tab styling
for (let w = 1; w <= 5; w++) {
const tab = document.getElementById(`tab-world-${w}`);
if (tab) {
if (w === activeWorldId) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
}
}
const levels = window.CampaignSystem.getLevelsByWorld(activeWorldId);
const world = window.CampaignSystem.getWorld(activeWorldId);
let html = '';
levels.forEach(lvl => {
const isPassed = lvl.level < this.currentLevel;
const isCurrent = lvl.level === this.currentLevel;
const isUnlocked = lvl.level <= (this.maxUnlockedLevel || 1);
let badgeClass = 'badge-locked';
let badgeText = '🔒 Заблокировано';
if (isCurrent) {
badgeClass = 'badge-current';
badgeText = '⚔️ Текущий';
} else if (isPassed) {
badgeClass = 'badge-done';
badgeText = '⭐ Пройдено';
} else if (isUnlocked) {
badgeClass = 'badge-unlocked';
badgeText = '🔓 Доступно';
}
const cardClass = `campaign-level-card${isCurrent ? ' current' : ''}${!isUnlocked ? ' locked' : ''}`;
const buttonHtml = isUnlocked
? `<button class="btn-play-level" data-level="${lvl.level}">ИГРАТЬ (ЭТАП ${lvl.code})</button>`
: `<button class="btn-play-level locked" disabled>🔒 ЗАКРЫТО</button>`;
const biomeNames = {
day: '☀️ День',
night: '🌙 Ночь',
pool: '🏊 Бассейн (6 линий)',
fog: '🌫️ Туман (6 линий)',
roof: '🏠 Крыша'
};
html += `
<div class="${cardClass}">
<div class="level-card-header">
<span class="level-code">${world.icon} Этап ${lvl.code}</span>
<span class="level-badge ${badgeClass}">${badgeText}</span>
</div>
<div class="level-card-body">
<div class="level-title">${lvl.title}</div>
<div class="level-desc">${lvl.desc}</div>
</div>
<div class="level-card-info">
<span>🌊 Волн: <strong>${lvl.waves}</strong></span>
<span>💰 Награда: <strong>+${lvl.reward}</strong></span>
<span>🏞️ ${biomeNames[lvl.biome] || lvl.biome}</span>
</div>
${buttonHtml}
</div>
`;
});
gridEl.innerHTML = html;
// Attach click listeners to level launch buttons
gridEl.querySelectorAll('.btn-play-level[data-level]').forEach(btn => {
btn.addEventListener('click', (e) => {
const targetLevel = parseInt(e.currentTarget.getAttribute('data-level'), 10);
if (targetLevel) {
const campModal = document.getElementById('campaign-modal');
if (campModal) campModal.classList.add('hidden');
const startModal = document.getElementById('start-modal');
if (startModal) startModal.classList.add('hidden');
if (window.PvZAudio) window.PvZAudio.init();
this.startLevel(targetLevel, 'adventure');
if (!this.loopStarted) {
this.loopStarted = true;
requestAnimationFrame(this.loop.bind(this));
}
}
});
});
}
highlightModeBtn(activeId) {
['btn-mode-adv', 'btn-mode-surv', 'btn-mode-bowl', 'btn-mode-izombie', 'btn-mode-rogue', 'btn-mode-boss', 'btn-mode-bossrush'].forEach(id => {
const btn = document.getElementById(id);
if (btn) {
const isActive = id === activeId;
btn.classList.toggle('active', isActive);
btn.style.borderColor = isActive ? '#ffeb3b' : '';
btn.style.opacity = isActive ? '1' : '0.7';
}
});
}
updateShopUI() {
const elCoins = document.getElementById('shop-coins');
if (elCoins) elCoins.textContent = `💰 Монеты: ${this.economy.totalCoins}`;
const btnBuySlot = document.getElementById('btn-buy-slot');
if (btnBuySlot) {
btnBuySlot.textContent = this.economy.upgrades.extraSlot ? 'Куплено ✔️' : 'Купить (500 💰)';
btnBuySlot.disabled = this.economy.upgrades.extraSlot || this.economy.totalCoins < 500;
}
const btnBuyRake = document.getElementById('btn-buy-rake');
if (btnBuyRake) {
btnBuyRake.textContent = this.economy.upgrades.hasRake ? 'Куплено ✔️' : 'Купить (150 💰)';
btnBuyRake.disabled = this.economy.upgrades.hasRake || this.economy.totalCoins < 150;
}
}
updateWorkshopUI() {
if (!this.workshop) return;
// 1. Materials bar
const matBio = document.getElementById('mat-bio');
const matScrap = document.getElementById('mat-scrap');
const matSun = document.getElementById('mat-sun');
const matCrystal = document.getElementById('mat-crystal');
const matCoins = document.getElementById('mat-coins');
if (matBio) matBio.textContent = this.workshop.materials.bioHumus;
if (matScrap) matScrap.textContent = this.workshop.materials.scrapMetal;
if (matSun) matSun.textContent = this.workshop.materials.sunEssence;
if (matCrystal) matCrystal.textContent = this.workshop.materials.crystals;
if (matCoins) matCoins.textContent = this.economy ? this.economy.totalCoins : 0;
// 2. Plants Upgrades List
const plantsContainer = document.getElementById('plants-upgrade-list');
if (plantsContainer) {
plantsContainer.innerHTML = '';
const pDefs = this.workshop.plantDefs;
for (const [id, def] of Object.entries(pDefs)) {
const lvl = this.workshop.getPlantLevel(id);
const curTier = def.tiers[lvl];
const nextTier = lvl < 3 ? def.tiers[lvl + 1] : null;
const card = document.createElement('div');
card.className = 'upgrade-card';
let costHtml = '';
let canAfford = false;
if (nextTier) {
canAfford = this.workshop.canAffordCost(nextTier.cost);
const parts = [];
if (nextTier.cost.coins) {
const hasCoins = this.economy && this.economy.totalCoins >= nextTier.cost.coins;
parts.push(`<span class="cost-badge ${hasCoins ? '' : 'insufficient'}">💰 ${nextTier.cost.coins}</span>`);
}
if (nextTier.cost.bioHumus) {
const hasBio = this.workshop.materials.bioHumus >= nextTier.cost.bioHumus;
parts.push(`<span class="cost-badge ${hasBio ? '' : 'insufficient'}">🧪 ${nextTier.cost.bioHumus}</span>`);
}
if (nextTier.cost.scrapMetal) {
const hasScrap = this.workshop.materials.scrapMetal >= nextTier.cost.scrapMetal;
parts.push(`<span class="cost-badge ${hasScrap ? '' : 'insufficient'}">⚙️ ${nextTier.cost.scrapMetal}</span>`);
}
if (nextTier.cost.sunEssence) {
const hasSun = this.workshop.materials.sunEssence >= nextTier.cost.sunEssence;
parts.push(`<span class="cost-badge ${hasSun ? '' : 'insufficient'}">☀️ ${nextTier.cost.sunEssence}</span>`);
}
if (nextTier.cost.crystals) {
const hasCryst = this.workshop.materials.crystals >= nextTier.cost.crystals;
parts.push(`<span class="cost-badge ${hasCryst ? '' : 'insufficient'}">💎 ${nextTier.cost.crystals}</span>`);
}
costHtml = parts.join(' ');
}
const stars = lvl === 3 ? '★★★' : (lvl === 2 ? '★★☆' : '★☆☆');
const badgeClass = lvl === 3 ? 'upgrade-level-badge maxed' : 'upgrade-level-badge';
card.innerHTML = `
<div class="upgrade-card-header">
<div class="upgrade-title"><span>${def.icon}</span> <span>${def.name}</span></div>
<div class="${badgeClass}">${stars} Ур. ${lvl}</div>
</div>
<div class="upgrade-desc">
<strong>Текущий эффект:</strong> ${curTier.desc}
</div>
${nextTier ? `
<div class="upgrade-stats-next">
<strong>След. ранг:</strong> ${nextTier.title} — ${nextTier.desc}
</div>
<div class="upgrade-cost-row">
<div class="cost-items">${costHtml}</div>
<button class="btn-upgrade-action" ${canAfford ? '' : 'disabled'} data-upgrade-plant="${id}">
Улучшить
</button>
</div>
` : `
<div class="upgrade-stats-next" style="border-left-color: #ffd700; color: #ffe082;">
Максимальный 3-й уровень разблокирован!
</div>
`}
`;
plantsContainer.appendChild(card);
}
// Bind upgrade buttons for plants
plantsContainer.querySelectorAll('[data-upgrade-plant]').forEach(btn => {
btn.addEventListener('click', (e) => {
const plantId = e.currentTarget.getAttribute('data-upgrade-plant');
if (this.workshop.upgradePlant(plantId)) {
this.saveUserData();
this.updateWorkshopUI();
}
});
});
}
// 3. Perks Upgrades List
const perksContainer = document.getElementById('perks-upgrade-list');
if (perksContainer) {
perksContainer.innerHTML = '';
const perkDefs = this.workshop.perkDefs;
for (const [id, def] of Object.entries(perkDefs)) {
const lvl = this.workshop.getPerkLevel(id);
const curTier = lvl > 0 ? def.tiers[lvl] : null;
const nextTier = lvl < 3 ? def.tiers[lvl + 1] : null;
const card = document.createElement('div');
card.className = 'upgrade-card';
let costHtml = '';
let canAfford = false;
if (nextTier) {
canAfford = this.workshop.canAffordCost(nextTier.cost);
const parts = [];
if (nextTier.cost.coins) {
const hasCoins = this.economy && this.economy.totalCoins >= nextTier.cost.coins;
parts.push(`<span class="cost-badge ${hasCoins ? '' : 'insufficient'}">💰 ${nextTier.cost.coins}</span>`);
}
if (nextTier.cost.bioHumus) {
const hasBio = this.workshop.materials.bioHumus >= nextTier.cost.bioHumus;
parts.push(`<span class="cost-badge ${hasBio ? '' : 'insufficient'}">🧪 ${nextTier.cost.bioHumus}</span>`);
}
if (nextTier.cost.scrapMetal) {
const hasScrap = this.workshop.materials.scrapMetal >= nextTier.cost.scrapMetal;
parts.push(`<span class="cost-badge ${hasScrap ? '' : 'insufficient'}">⚙️ ${nextTier.cost.scrapMetal}</span>`);
}
if (nextTier.cost.sunEssence) {
const hasSun = this.workshop.materials.sunEssence >= nextTier.cost.sunEssence;
parts.push(`<span class="cost-badge ${hasSun ? '' : 'insufficient'}">☀️ ${nextTier.cost.sunEssence}</span>`);
}
if (nextTier.cost.crystals) {
const hasCryst = this.workshop.materials.crystals >= nextTier.cost.crystals;
parts.push(`<span class="cost-badge ${hasCryst ? '' : 'insufficient'}">💎 ${nextTier.cost.crystals}</span>`);
}
costHtml = parts.join(' ');
}
const stars = lvl === 3 ? '★★★' : (lvl === 2 ? '★★☆' : (lvl === 1 ? '★☆☆' : '☆☆☆'));
const badgeClass = lvl === 3 ? 'upgrade-level-badge maxed' : 'upgrade-level-badge';
card.innerHTML = `
<div class="upgrade-card-header">
<div class="upgrade-title"><span>${def.icon}</span> <span>${def.name}</span></div>
<div class="${badgeClass}">${stars} Ур. ${lvl}</div>
</div>
<div class="upgrade-desc">
${def.desc}. ${curTier ? `<strong>(Сейчас: ${curTier.desc})</strong>` : '<em>(Не изучен)</em>'}
</div>
${nextTier ? `
<div class="upgrade-stats-next">
<strong>След. уровень:</strong> ${nextTier.desc}
</div>
<div class="upgrade-cost-row">
<div class="cost-items">${costHtml}</div>
<button class="btn-upgrade-action" ${canAfford ? '' : 'disabled'} data-upgrade-perk="${id}">
Изучить
</button>
</div>
` : `
<div class="upgrade-stats-next" style="border-left-color: #ffd700; color: #ffe082;">
Перк полностью освоен!
</div>
`}
`;
perksContainer.appendChild(card);
}
// Bind upgrade buttons for perks
perksContainer.querySelectorAll('[data-upgrade-perk]').forEach(btn => {
btn.addEventListener('click', (e) => {
const perkId = e.currentTarget.getAttribute('data-upgrade-perk');
if (this.workshop.upgradePerk(perkId)) {
this.saveUserData();
this.updateWorkshopUI();
}
});
});
}
}
initZenGarden() {
if (!this.zengarden) return;
this.zenTool = 'water'; // 'water', 'fertilizer', 'music'
const zenCanvas = document.getElementById('zenCanvas');
if (!zenCanvas) return;
const zenCtx = zenCanvas.getContext('2d');
// Tool selectors
const btnWater = document.getElementById('zen-tool-water');
const btnFert = document.getElementById('zen-tool-fert');
const btnMusic = document.getElementById('zen-tool-music');
const btnFeedSnail = document.getElementById('zen-feed-snail');
const btnPlantSprout = document.getElementById('zen-plant-sprout');
const updateToolBtns = () => {
[btnWater, btnFert, btnMusic].forEach(b => { if (b) b.classList.remove('active'); });
if (this.zenTool === 'water' && btnWater) btnWater.classList.add('active');
if (this.zenTool === 'fertilizer' && btnFert) btnFert.classList.add('active');
if (this.zenTool === 'music' && btnMusic) btnMusic.classList.add('active');
const fertCount = document.getElementById('zen-fert-count');
const chocCount = document.getElementById('zen-choc-count');
if (fertCount) fertCount.textContent = this.zengarden.fertilizerCount;
if (chocCount) chocCount.textContent = this.zengarden.chocolateCount;
};
if (btnWater) btnWater.addEventListener('click', () => { this.zenTool = 'water'; updateToolBtns(); });
if (btnFert) btnFert.addEventListener('click', () => { this.zenTool = 'fertilizer'; updateToolBtns(); });
if (btnMusic) btnMusic.addEventListener('click', () => { this.zenTool = 'music'; updateToolBtns(); });
if (btnFeedSnail) {
btnFeedSnail.addEventListener('click', () => {
if (this.zengarden.feedSnail()) {
this.saveUserData();
updateToolBtns();
}
});
}
if (btnPlantSprout) {
btnPlantSprout.addEventListener('click', () => {
if (this.economy && this.economy.totalCoins >= 100) {
const types = ['sunflower', 'peashooter', 'wallnut', 'snowpea', 'repeater', 'chomper'];
const picked = types[Math.floor(Math.random() * types.length)];
if (this.zengarden.plantSprout(picked)) {
this.economy.addCoins(-100);
if (window.PvZAudio) window.PvZAudio.playWin();
this.saveUserData();
}
}
});
}
// Canvas pot click
zenCanvas.addEventListener('click', (e) => {
const rect = zenCanvas.getBoundingClientRect();
const scaleX = zenCanvas.width / rect.width;
const scaleY = zenCanvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
// Check coins click
for (const c of this.zengarden.gardenCoins) {
if (!c.collected && Math.hypot(mx - c.x, my - c.y) < 25) {
this.zengarden.collectCoin(c);
return;
}
}
// Check pots click (col: 0..3, row: 0..1)
for (let i = 0; i < 8; i++) {
const col = i % 4;
const row = Math.floor(i / 4);
const px = 120 + col * 150;
const py = row === 0 ? 150 : 275;
if (Math.abs(mx - px) < 45 && my >= py - 50 && my <= py + 30) {
if (this.zenTool === 'water') {
this.zengarden.waterPlant(i);
} else if (this.zenTool === 'fertilizer') {
this.zengarden.fertilizePlant(i);
} else if (this.zenTool === 'music') {
this.zengarden.playMusicToPlant(i);
}
this.saveUserData();
updateToolBtns();
return;
}
}
});
updateToolBtns();
// Zen Garden Animation Loop
let lastZenTime = performance.now();
const zenLoop = (now) => {
const modal = document.getElementById('zengarden-modal');
if (modal && !modal.classList.contains('hidden')) {
const dt = Math.min((now - lastZenTime) / 1000, 0.1);
this.zengarden.update(dt);
this.zengarden.draw(zenCtx);
}
lastZenTime = now;
requestAnimationFrame(zenLoop);
};
requestAnimationFrame(zenLoop);
}
updateZenGardenUI() {
if (!this.zengarden) return;
const fertCount = document.getElementById('zen-fert-count');
const chocCount = document.getElementById('zen-choc-count');
if (fertCount) fertCount.textContent = this.zengarden.fertilizerCount;
if (chocCount) chocCount.textContent = this.zengarden.chocolateCount;
const alertEl = document.getElementById('zen-offline-alert');
if (alertEl) {
if (this.zengarden.offlineEarned > 0) {
alertEl.textContent = `💰 Пассивный доход за время отсутствия: +${this.zengarden.offlineEarned} монет!`;
} else {
alertEl.textContent = `💰 Взрослые растения приносят монеты каждые несколько секунд.`;
}
}
}
initAlmanac() {
if (!this.almanac) return;
const tabPlants = document.getElementById('tab-almanac-plants');
const tabZombies = document.getElementById('tab-almanac-zombies');
if (tabPlants) {
tabPlants.addEventListener('click', () => {
this.almanac.selectedCategory = 'plants';
this.almanac.selectedId = 'peashooter';
tabPlants.classList.add('active');
if (tabZombies) tabZombies.classList.remove('active');
this.renderAlmanacUI();
});
}
if (tabZombies) {
tabZombies.addEventListener('click', () => {
this.almanac.selectedCategory = 'zombies';
this.almanac.selectedId = 'z_normal';
tabZombies.classList.add('active');
if (tabPlants) tabPlants.classList.remove('active');
this.renderAlmanacUI();
});
}
}
renderAlmanacUI() {
if (!this.almanac) return;
const listContainer = document.getElementById('almanac-list');
const detailsContainer = document.getElementById('almanac-details');
if (!listContainer || !detailsContainer) return;
const items = this.almanac.selectedCategory === 'plants' ? this.almanac.plants : this.almanac.zombies;
listContainer.innerHTML = '';
// Render grid items
for (const item of items) {
const slot = document.createElement('div');
const isSelected = item.id === this.almanac.selectedId;
slot.className = isSelected ? 'almanac-slot selected' : 'almanac-slot';
slot.innerHTML = `
<div class="almanac-slot-icon">${item.icon}</div>
<div class="almanac-slot-name">${item.name}</div>
`;
slot.addEventListener('click', () => {
this.almanac.selectedId = item.id;
this.renderAlmanacUI();
if (window.PvZAudio) window.PvZAudio.playCoin();
});
listContainer.appendChild(slot);
}
// Render detailed view for selected item
const selectedItem = this.almanac.getItem(this.almanac.selectedId) || items[0];
if (selectedItem) {
let statsHtml = '';
if (this.almanac.selectedCategory === 'plants') {
statsHtml = `
<div class="almanac-stat-row"><span>Стоимость:</span> <strong>☀️ ${selectedItem.cost}</strong></div>
<div class="almanac-stat-row"><span>Перезарядка:</span> <strong>${selectedItem.cooldown}</strong></div>
<div class="almanac-stat-row"><span>Урон:</span> <strong>${selectedItem.damage}</strong></div>
<div class="almanac-stat-row"><span>Прочность:</span> <strong>${selectedItem.toughness}</strong></div>
`;
} else {
statsHtml = `
<div class="almanac-stat-row"><span>Скорость:</span> <strong>${selectedItem.speed}</strong></div>
<div class="almanac-stat-row"><span>Прочность:</span> <strong>${selectedItem.toughness}</strong></div>
`;
}
detailsContainer.innerHTML = `
<div class="almanac-detail-title">
<div class="almanac-detail-icon">${selectedItem.icon}</div>
<div class="almanac-detail-name">${selectedItem.name}</div>
</div>
<div class="almanac-stats-box" style="display: flex; flex-direction: column; gap: 4px;">
${statsHtml}
</div>
<div class="almanac-desc-text">
${selectedItem.desc}
</div>
<div class="almanac-lore-quote">
${selectedItem.lore}
</div>
`;
}
}
initFeedbackUI() {
// Bug Filter Tabs
const bugTabs = [
{ id: 'tab-bug-all', filter: 'all' },
{ id: 'tab-bug-bugs', filter: 'bug' },
{ id: 'tab-bug-ideas', filter: 'feature' },
{ id: 'tab-bug-visual', filter: 'visual' },
{ id: 'tab-bug-fixed', filter: 'resolved' }
];
bugTabs.forEach(t => {
const el = document.getElementById(t.id);
if (el) {
el.addEventListener('click', () => {
bugTabs.forEach(other => {
const oEl = document.getElementById(other.id);
if (oEl) oEl.classList.remove('active');
});
el.classList.add('active');
if (window.PvZFeedback) {
window.PvZFeedback.setFilter(t.filter);
}
});
}
});
// Bug Form Submit
const btnBugSubmit = document.getElementById('btn-bug-submit');
if (btnBugSubmit) {
btnBugSubmit.addEventListener('click', () => {
const nameInput = document.getElementById('bug-input-name');
const catInput = document.getElementById('bug-input-category');
const textInput = document.getElementById('bug-input-text');
const statusEl = document.getElementById('bug-form-status');
const author = nameInput ? nameInput.value.trim() : '';
const cat = catInput ? catInput.value : 'bug';
const text = textInput ? textInput.value.trim() : '';
if (!text) {
if (statusEl) {
statusEl.textContent = 'Пожалуйста, опишите проблему перед отправкой!';
statusEl.className = 'bug-status-msg error';
statusEl.classList.remove('hidden');
}
if (textInput) textInput.focus();
return;
}
btnBugSubmit.disabled = true;
btnBugSubmit.textContent = 'Отправка... ⏳';
if (window.PvZFeedback) {
window.PvZFeedback.sendReport(author, cat, text, (res) => {
btnBugSubmit.disabled = false;
btnBugSubmit.textContent = 'Отправить 🚀';
if (res && res.success) {
if (textInput) textInput.value = '';
if (statusEl) {
statusEl.textContent = res.offline
? 'Сохранено локально! Сообщение появится в чате.'
: 'Спасибо! Баг-репорт успешно сохранен на сервере ✅';
statusEl.className = 'bug-status-msg success';
statusEl.classList.remove('hidden');
setTimeout(() => {
if (statusEl) statusEl.classList.add('hidden');
}, 4000);
}
} else {
if (statusEl) {
statusEl.textContent = (res && res.message) || 'Ошибка отправки репорта';
statusEl.className = 'bug-status-msg error';
statusEl.classList.remove('hidden');
}
}
});
}
});
}
}
renderAchievementsUI() {
if (!this.achievements) return;
const listEl = document.getElementById('achievements-list');
if (!listEl) return;
listEl.innerHTML = '';
for (const ach of this.achievements.achievements) {
const card = document.createElement('div');
const isDone = ach.completed;
const isClaimed = ach.claimed;
card.className = `achievement-item ${isDone ? 'completed' : ''} ${isClaimed ? 'claimed' : ''}`;
const pct = Math.min(100, Math.round((ach.progress / ach.max) * 100));
let rewardText = `💰 +${ach.reward.coins}`;
if (ach.reward.crystals) {
rewardText += ` &bull; 💎 +${ach.reward.crystals}`;
}
let btnHtml = '';
if (isClaimed) {
btnHtml = `<button class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; cursor: default; opacity: 0.6;" disabled>Получено ✓</button>`;
} else if (isDone) {
btnHtml = `<button class="btn btn-claim-reward" data-ach="${ach.id}" style="font-size: 11px; padding: 4px 10px; background: #ffd54f; color: #1b5e20; font-weight: bold;">Забрать 🎁</button>`;
} else {
btnHtml = `<button class="btn btn-secondary" style="font-size: 11px; padding: 4px 8px; opacity: 0.5;" disabled>${ach.progress} / ${ach.max}</button>`;
}
card.innerHTML = `
<div class="ach-icon">${ach.icon}</div>
<div class="ach-info">
<div class="ach-title">${ach.title}</div>
<div class="ach-desc">${ach.desc}</div>
<div class="ach-progress-bar">
<div class="ach-progress-fill" style="width: ${pct}%;"></div>
</div>
<div class="ach-reward-tag">Награда: ${rewardText}</div>
</div>
<div class="ach-action">
${btnHtml}
</div>
`;
const claimBtn = card.querySelector('.btn-claim-reward');
if (claimBtn) {
claimBtn.addEventListener('click', () => {
this.achievements.claimReward(ach.id);
this.renderAchievementsUI();
this.updateRecordDisplay();
this.updateShopUI();
});
}
listEl.appendChild(card);
}
}
renderTreeUI() {
if (!this.tree) return;
const heightEl = document.getElementById('tree-height-display');
const artEl = document.getElementById('tree-art');
const titleEl = document.getElementById('tree-wisdom-title');
const quoteEl = document.getElementById('tree-wisdom-quote');
const cheatsListEl = document.getElementById('tree-cheats-list');
const feedBtn = document.getElementById('btn-tree-feed');
const height = this.tree.height;
if (heightEl) heightEl.textContent = `Высота: ${height} футов | Древесное удобрение: 150 💰`;
if (artEl) {
if (height >= 100) artEl.textContent = '🌲✨';
else if (height >= 50) artEl.textContent = '🌳';
else if (height >= 20) artEl.textContent = '🪴';
else if (height >= 5) artEl.textContent = '🌿';
else artEl.textContent = '🌱';
}
const wisdom = this.tree.getCurrentWisdom();
if (titleEl) titleEl.textContent = wisdom.title || 'Первый росток';
if (quoteEl) quoteEl.textContent = wisdom.text || '';
if (feedBtn) {
const canAfford = this.economy && this.economy.totalCoins >= this.tree.fertilizerCost;
feedBtn.disabled = !canAfford;
feedBtn.textContent = `Подкормить Древо (${this.tree.fertilizerCost} 💰)`;
}
if (cheatsListEl) {
cheatsListEl.innerHTML = '';
const cheatMeta = [
{ id: 'mustache', name: 'Усы (Mustache)', reqHeight: 10, icon: '👨🏻' },
{ id: 'future', name: 'Киберочки (Future)', reqHeight: 30, icon: '🕶️' },
{ id: 'daisies', name: 'Цветы (Daisies)', reqHeight: 50, icon: '🌼' },
{ id: 'dance', name: 'Танцы (Dance)', reqHeight: 100, icon: '🕺' },
{ id: 'pinata', name: 'Конфетти (Pinata)', reqHeight: 100, icon: '🎉' }
];
cheatMeta.forEach(c => {
const isUnlocked = height >= c.reqHeight;
const isActive = !!this.tree.cheats[c.id];
const item = document.createElement('div');
item.className = `tree-cheat-item ${isUnlocked ? (isActive ? 'active' : '') : 'locked'}`;
item.innerHTML = `
<div class="tree-cheat-info">
<span style="font-size: 18px; margin-right: 6px;">${c.icon}</span>
<strong>${c.name}</strong>
<div style="font-size: 11px; opacity: 0.8;">
${isUnlocked ? (isActive ? 'Статус: ВКЛЮЧЕН ✅' : 'Статус: Выключен') : `Требуется: ${c.reqHeight} футов 🔒`}
</div>
</div>
<button class="btn" style="font-size: 11px; padding: 4px 10px; ${isActive ? 'background: #e91e63;' : ''}" ${isUnlocked ? '' : 'disabled'}>
${isUnlocked ? (isActive ? 'Выкл' : 'Вкл') : '🔒'}
</button>
`;
const toggleBtn = item.querySelector('button');
if (toggleBtn && isUnlocked) {
toggleBtn.addEventListener('click', () => {
this.tree.toggleCheat(c.id);
this.renderTreeUI();
});
}
cheatsListEl.appendChild(item);
});
}
}
renderLeaderboardUI(mode = 'survival') {
if (!this.leaderboards) return;
this.leaderboards.selectedMode = mode;
const contentEl = document.getElementById('leaderboard-content');
if (!contentEl) return;
const entries = this.leaderboards.getEntries(mode);
if (!entries || entries.length === 0) {
contentEl.innerHTML = `<div style="text-align: center; color: #ffe082; padding: 24px; font-size: 14px;">Пока нет записей. Сыграйте в этот режим, чтобы установить первый рекорд!</div>`;
return;
}
let rowsHtml = '';
entries.forEach((item, index) => {
let medal = `#${index + 1}`;
if (index === 0) medal = '🥇 1';
else if (index === 1) medal = '🥈 2';
else if (index === 2) medal = '🥉 3';
rowsHtml += `
<tr class="${index < 3 ? 'top-rank' : ''}">
<td style="font-weight: bold; width: 60px; text-align: center;">${medal}</td>
<td style="font-weight: bold; color: #fff;">${item.name}</td>
<td style="color: #ffd54f; font-weight: bold;">${item.detail || item.score}</td>
<td style="color: #b0bec5; font-size: 11px; text-align: right;">${item.date || ''}</td>
</tr>
`;
});
contentEl.innerHTML = `
<table class="leaderboard-table">
<thead>
<tr>
<th style="width: 60px; text-align: center;">Место</th>
<th>Игрок</th>
<th>Результат</th>
<th style="text-align: right;">Дата</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
`;
}
showDraftModal() {
if (!this.roguelike) return;
const modal = document.getElementById('draft-modal');
const listEl = document.getElementById('draft-choices-list');
if (!modal || !listEl) return;
const choices = this.roguelike.generateDraftChoices();
listEl.innerHTML = '';
choices.forEach((choice, index) => {
const card = document.createElement('div');
card.className = `draft-card-item ${choice.type === 'relic' ? 'relic-choice' : 'seed-choice'}`;
card.innerHTML = `
<div class="draft-choice-type">${choice.type === 'relic' ? '✨ РЕЛИКВИЯ' : '🌱 СЕМЯ РАСТЕНИЯ'}</div>
<div class="draft-choice-icon">${choice.icon}</div>
<div class="draft-choice-title">${choice.name}</div>
<div class="draft-choice-desc">${choice.desc}</div>
<button class="btn draft-choice-btn" style="margin-top: 10px; width: 100%;">Выбрать</button>
`;
const pickBtn = card.querySelector('.draft-choice-btn');
if (pickBtn) {
pickBtn.addEventListener('click', () => {
this.roguelike.selectChoice(index);
modal.classList.add('hidden');
if (window.PvZAudio) window.PvZAudio.playCoin();
this.startLevel(this.roguelike.currentFloor, 'roguelike');
});
}
listEl.appendChild(card);
});
modal.classList.remove('hidden');
}
createPlant(type, col, row, bounds) {
const level = this.workshop ? this.workshop.getPlantLevel(type) : 1;
const plant = new window.Plant(type, col, row, bounds.x, bounds.y, bounds.w, bounds.h, level);
if (this.workshop) {
const hardyLvl = this.workshop.getPerkLevel('hardyRoots');
if (hardyLvl > 0) {
const mult = 1 + (hardyLvl === 1 ? 0.20 : (hardyLvl === 2 ? 0.35 : 0.50));
plant.maxHealth = Math.round(plant.maxHealth * mult);
plant.health = plant.maxHealth;
}
}
// Roguelike: relic_iron_bark (+350 HP to Wallnut)
if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_iron_bark') && type === 'wallnut') {
plant.maxHealth += 350;
plant.health = plant.maxHealth;
}
// Roguelike: relic_fertilizer_boost (20% faster shootInterval)
if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_fertilizer_boost')) {
if (plant.shootInterval) {
plant.shootInterval *= 0.8;
}
}
// Achievement: first_plant
if (this.achievements) {
this.achievements.trigger('first_plant');
}
return plant;
}
handleCellClick(col, row) {
// 0. I, Zombie Mechanic: Click row to deploy zombie!
if (this.currentMode === 'izombie') {
const selected = this.economy.selectedCard;
if (!selected) return;
const card = this.economy.cards.find(c => c.id === selected);
if (card && this.economy.spendSun(card.cost) && card.timer <= 0) {
this.economy.startCooldown(card.id);
const zType = selected.replace('z_', '');
this.spawnZombie(row, zType);
if (window.PvZAudio) window.PvZAudio.playPlant();
this.economy.selectedCard = null;
}
return;
}
// 1. Wall-nut Bowling Mechanic
if (this.currentMode === 'bowling') {
const selected = this.economy.selectedCard;
if (!selected) return;
const card = this.economy.cards.find(c => c.id === selected);
if (card && card.timer <= 0) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(0, row);
if (selected === 'bowling_cherry') {
// Exploding bowling cherry
this.explodeArea(col, row, 3);
if (window.PvZAudio) window.PvZAudio.playExplosion();
} else {
// Rolling bowling nut
const ball = new window.Projectile(130, bounds.centerY, row, 'bowling', 0);
this.projectiles.push(ball);
if (window.PvZAudio) window.PvZAudio.playPlant();
}
this.economy.selectedCard = null;
}
return;
}
// 2. Standard Planting Mechanic
const selected = this.economy.selectedCard;
if (!selected) return;
const existingPlant = this.grid.getPlant(col, row);
const isWater = this.grid.isWaterCell(row);
const hasGrave = this.grid.hasGrave(col, row);
// Shovel
if (selected === 'shovel') {
if (existingPlant) {
this.grid.removePlant(col, row);
existingPlant.toRemove = true;
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
return;
}
// Gravebuster
if (selected === 'gravebuster') {
if (hasGrave && !existingPlant) {
const card = this.economy.cards.find(c => c.id === selected);
if (card && this.economy.spendSun(card.cost)) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(col, row);
const plant = this.createPlant(card.id, col, row, bounds);
this.grid.setPlant(col, row, plant);
this.plants.push(plant);
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
}
return;
}
// Cannot plant on Gravestone with anything else
if (hasGrave) return;
// Water lane checks (Pool biome)
if (isWater) {
if (selected === 'lilypad' && !existingPlant) {
// Plant Lilypad
const card = this.economy.cards.find(c => c.id === selected);
if (card && this.economy.spendSun(card.cost)) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(col, row);
const plant = this.createPlant('lilypad', col, row, bounds);
this.grid.setPlant(col, row, plant);
this.plants.push(plant);
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
return;
} else if (existingPlant && existingPlant.type === 'lilypad') {
// Plant normal plant on top of Lilypad!
const card = this.economy.cards.find(c => c.id === selected);
if (card && selected !== 'lilypad' && this.economy.spendSun(card.cost)) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(col, row);
const plant = this.createPlant(card.id, col, row, bounds);
this.grid.setPlant(col, row, plant);
this.plants.push(plant);
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
return;
} else {
return; // Water requires lilypad first
}
}
// Roof checks (Roof biome requires flowerpot)
if (this.grid.isRoof()) {
if (selected === 'flowerpot' && !existingPlant) {
const card = this.economy.cards.find(c => c.id === selected);
if (card && this.economy.canAfford(card.cost) && card.timer <= 0) {
if (this.economy.spendSun(card.cost)) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(col, row);
const plant = this.createPlant('flowerpot', col, row, bounds);
this.grid.setPlant(col, row, plant);
this.plants.push(plant);
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
}
return;
} else if (existingPlant && existingPlant.type === 'flowerpot') {
const card = this.economy.cards.find(c => c.id === selected);
if (card && selected !== 'flowerpot' && this.economy.canAfford(card.cost) && card.timer <= 0) {
if (this.economy.spendSun(card.cost)) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(col, row);
const plant = this.createPlant(card.id, col, row, bounds);
this.grid.setPlant(col, row, plant);
this.plants.push(plant);
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
}
return;
} else {
return; // Roof requires flowerpot first
}
}
// Normal Grass Planting
if (!existingPlant) {
const card = this.economy.cards.find(c => c.id === selected);
if (card && this.economy.canAfford(card.cost) && card.timer <= 0) {
if (this.economy.spendSun(card.cost)) {
this.economy.startCooldown(card.id);
const bounds = this.grid.getCellBounds(col, row);
const plant = this.createPlant(card.id, col, row, bounds);
this.grid.setPlant(col, row, plant);
this.plants.push(plant);
this.economy.selectedCard = null;
if (window.PvZAudio) window.PvZAudio.playPlant();
}
}
}
}
loop(timestamp) {
const rawDt = Math.min((timestamp - this.lastTime) / 1000, 0.1);
this.lastTime = timestamp;
const dt = rawDt * this.gameSpeed;
if (this.isRunning && !this.isPaused) {
this.update(dt);
}
this.render();
requestAnimationFrame(this.loop.bind(this));
}
update(dt) {
// 1. Grid & Lawnmowers
this.grid.updateLawnmowers(dt, this.zombies);
// 2. Economy & Workshop Loot & Zen Garden
this.economy.update(dt);
if (this.workshop) {
this.workshop.update(dt, this);
}
if (this.zengarden) {
this.zengarden.update(dt * 0.2);
}
// 3. Waves & Spawns
this.wave.update(dt, this);
// 4. Garden Rake Check
if (this.hasRakeArmed) {
const rakeBounds = this.grid.getCellBounds(this.rakeCol, this.rakeRow);
for (const z of this.zombies) {
if (z.row === this.rakeRow && Math.abs(z.x - rakeBounds.centerX) < 30 && z.health > 0) {
this.hasRakeArmed = false;
z.takeDamage(1000);
this.combat.createExplosionParticles(rakeBounds.centerX, rakeBounds.centerY);
if (window.PvZAudio) window.PvZAudio.playStrike();
break;
}
}
}
// 5. Plants
for (const plant of this.plants) {
plant.update(dt, this);
if (plant.toRemove) {
this.grid.removePlant(plant.col, plant.row);
if (this.currentMode === 'izombie' && plant.type === 'sunflower') {
this.economy.addSun(150);
if (window.PvZAudio) window.PvZAudio.playSun();
}
}
}
this.plants = this.plants.filter(p => !p.toRemove);
// 6. Zombies
for (const zombie of this.zombies) {
zombie.update(dt, this);
}
this.zombies = this.zombies.filter(z => !z.toRemove);
// 7. Projectiles
for (const proj of this.projectiles) {
proj.update(dt);
}
this.projectiles = this.projectiles.filter(p => !p.toRemove);
// 8. Combat
this.combat.update(dt, this);
// 8.5 Roguelike Relics & Timers
if (this.roguelike && this.currentMode === 'roguelike') {
this.roguelike.update(dt, this);
}
// 8.6 Dr. Zomboss Boss Fight
if (this.boss && (this.boss.isActive || this.currentMode === 'boss' || this.currentMode === 'bossrush')) {
this.boss.update(dt, this);
}
// 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) {
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('Закончились солнце и зомби! Подсолнухи уцелели.');
}
}
}
}
render() {
this.ctx.clearRect(0, 0, this.logicalWidth, this.logicalHeight);
// 1. Grid & Biome
this.grid.draw(this.ctx, this.hoverCell, this.economy.selectedCard);
// 2. Garden Rake
if (this.hasRakeArmed) {
const rakeBounds = this.grid.getCellBounds(this.rakeCol, this.rakeRow);
this.drawRake(this.ctx, rakeBounds.centerX, rakeBounds.centerY);
}
// 3. Plants
for (const plant of this.plants) {
plant.draw(this.ctx);
}
// 4. Zombies (depth sort)
const sortedZombies = [...this.zombies].sort((a, b) => a.y - b.y);
for (const zombie of sortedZombies) {
zombie.draw(this.ctx, this);
}
// 5. Projectiles
for (const proj of this.projectiles) {
proj.draw(this.ctx);
}
// 5.5 Workshop Floating Loot
if (this.workshop) {
this.workshop.draw(this.ctx);
}
// 6. Combat Particles
this.combat.draw(this.ctx);
// 7. Top UI & Suns
this.economy.draw(this.ctx);
// 8. Wave status & Banners
this.wave.draw(this.ctx);
// 8.5 Roguelike Active Relics HUD
if (this.roguelike && this.currentMode === 'roguelike') {
this.roguelike.draw(this.ctx);
}
// 8.6 Dr. Zomboss Robot & Hazards
if (this.boss && (this.boss.isActive || this.currentMode === 'boss' || this.currentMode === 'bossrush')) {
this.boss.draw(this.ctx);
}
// 9. Ghost Preview
if (this.hoverCell && this.economy.selectedCard && this.economy.selectedCard !== 'shovel') {
const bounds = this.grid.getCellBounds(this.hoverCell.col, this.hoverCell.row);
this.ctx.save();
this.ctx.globalAlpha = 0.55;
this.economy.drawCardThumbnail(this.ctx, this.economy.selectedCard, bounds.centerX, bounds.centerY);
this.ctx.restore();
}
// 10. Level & Score indicator in top right
this.ctx.save();
this.ctx.fillStyle = '#ffeb3b';
this.ctx.font = 'bold 12px sans-serif';
this.ctx.textAlign = 'right';
const modeLabel = this.currentMode === 'survival'
? 'Выживание'
: (this.currentMode === 'bowling'
? 'Боулинг'
: (this.currentMode === 'boss'
? 'Босс: Д-р Зомбосс'
: (this.currentMode === 'roguelike' ? `Рогалик: Этаж ${this.roguelike ? this.roguelike.currentFloor : 1}` : `Уровень ${this.currentLevel}`)));
this.ctx.fillText(`${modeLabel} | Счёт: ${this.score}`, 880, 78);
this.ctx.restore();
// 11. Pause Banner Overlay
if (this.isPaused) {
this.ctx.save();
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.65)';
this.ctx.fillRect(0, 0, this.logicalWidth, this.logicalHeight);
// Center Modal Card
this.ctx.fillStyle = '#2a160c';
this.ctx.strokeStyle = '#e09f3e';
this.ctx.lineWidth = 4;
this.ctx.beginPath();
this.ctx.roundRect(260, 210, 380, 180, 16);
this.ctx.fill();
this.ctx.stroke();
this.ctx.fillStyle = '#ffeb3b';
this.ctx.font = 'bold 28px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('⏸️ ИГРА НА ПАУЗЕ', this.logicalWidth / 2, 270);
this.ctx.fillStyle = '#e0e0e0';
this.ctx.font = '14px sans-serif';
this.ctx.fillText('Все растения и зомби остановлены.', this.logicalWidth / 2, 308);
this.ctx.fillStyle = '#81c784';
this.ctx.font = 'bold 15px sans-serif';
this.ctx.fillText('Нажмите Пробел или кнопку «▶️ Играть»', this.logicalWidth / 2, 345);
this.ctx.restore();
}
}
drawRake(ctx, x, y) {
ctx.save();
ctx.translate(x, y);
ctx.strokeStyle = '#8d6e63';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-10, 18);
ctx.lineTo(15, -12);
ctx.stroke();
ctx.strokeStyle = '#757575';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(8, -16);
ctx.lineTo(20, -6);
for (let i = 0; i < 4; i++) {
ctx.moveTo(8 + i * 3, -16 + i * 2.5);
ctx.lineTo(5 + i * 3, -22 + i * 2.5);
}
ctx.stroke();
ctx.restore();
}
};