// 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.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();
}
updateProfileUI() {
const badgeName = document.getElementById('badge-user-name');
const btnAuth = document.getElementById('btn-auth-open');
const btnLogoutNav = document.getElementById('btn-auth-logout-nav');
const authStatus = document.getElementById('auth-current-status');
const btnLogoutModal = document.getElementById('btn-auth-logout');
const btnAuthGuest = document.getElementById('btn-auth-guest');
if (window.PvZAuth) {
const user = window.PvZAuth.getCurrentUser();
const isGuest = window.PvZAuth.isGuest();
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 (authStatus) {
authStatus.innerHTML = isGuest
? 'Текущий режим: Гость'
: `Вы вошли как: ${user}`;
}
if (btnLogoutModal) {
if (isGuest) btnLogoutModal.classList.add('hidden');
else btnLogoutModal.classList.remove('hidden');
}
if (btnAuthGuest) {
if (isGuest) btnAuthGuest.classList.remove('hidden');
else btnAuthGuest.classList.add('hidden');
}
}
}
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 = `🏆 Рекорд: ${this.highScore} очков | 💰 Монеты: ${this.economy.totalCoins} | Уровень: ${lvlLabel}`;
}
}
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 = `Счёт: ${this.score} | Зомби: ${this.zombiesKilled}
Рекорд: ${this.highScore} | Монеты: ${this.economy.totalCoins}`;
}
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 = `Победа! Все подсолнухи съедены! Мозги захвачены!
Награда: +100 💰 монет (Всего: ${this.economy.totalCoins})`;
} else if (this.currentMode === 'bossrush') {
statsEl.innerHTML = `Триумф! Режим Boss Rush успешно завершён!
Награда: +500 💰 монет • +2 💎 кристалла (Всего: ${this.economy.totalCoins})`;
} else if (this.currentMode === 'adventure' && window.CampaignSystem) {
const nextInfo = window.CampaignSystem.formatLevel(this.currentLevel);
statsEl.innerHTML = `Победа! Финальный счёт: ${this.score} (+1000 очков)
Награда за этап: +${levelReward} 💰 монет (Всего: ${this.economy.totalCoins})
Следующий этап: ${nextInfo.fullLabel}`;
} else {
statsEl.innerHTML = `Победа! Финальный счёт: ${this.score} (+1000 очков)
Награда: +100 💰 монет (Всего: ${this.economy.totalCoins})
Уровень разблокирован: ${this.currentLevel}`;
}
}
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 Drawer Handlers
const mobileDrawer = document.getElementById('mobile-drawer');
const drawerBackdrop = document.getElementById('drawer-backdrop');
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 (btnDrawerOpen) btnDrawerOpen.addEventListener('click', openDrawer);
if (btnDrawerClose) btnDrawerClose.addEventListener('click', closeDrawer);
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
// Mobile 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';
});
}
// 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 btnAuthSubmit = document.getElementById('btn-auth-submit');
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 handleLogout = () => {
if (window.PvZAuth) {
window.PvZAuth.logout();
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
this.updateShopUI();
}
if (authModal) authModal.classList.add('hidden');
if (authUser) authUser.value = '';
if (authPass) authPass.value = '';
if (authError) authError.classList.add('hidden');
if (window.PvZAudio) window.PvZAudio.playCoin();
};
if (btnAuthLogoutNav) {
btnAuthLogoutNav.addEventListener('click', handleLogout);
}
if (btnAuthLogout) {
btnAuthLogout.addEventListener('click', handleLogout);
}
if (btnAuthOpen && authModal) {
btnAuthOpen.addEventListener('click', () => {
if (authError) authError.classList.add('hidden');
this.updateProfileUI();
authModal.classList.remove('hidden');
if (authUser) authUser.focus();
});
}
if (btnAuthClose && authModal) {
btnAuthClose.addEventListener('click', () => {
authModal.classList.add('hidden');
});
}
if (btnAuthGuest && authModal) {
btnAuthGuest.addEventListener('click', () => {
handleLogout();
});
}
if (btnAuthSubmit && authModal) {
btnAuthSubmit.addEventListener('click', () => {
const u = authUser ? authUser.value.trim() : '';
const p = authPass ? authPass.value : '';
if (!u) {
if (authError) { authError.textContent = 'Введите имя пользователя'; authError.classList.remove('hidden'); }
return;
}
if (!p && u.toLowerCase() !== 'гость') {
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 = window.PvZAuth.login(u, p, (serverData, errMsg) => {
// Server response callback (async)
btnAuthSubmit.textContent = 'Войти / Регистрация';
btnAuthSubmit.disabled = false;
if (errMsg) {
// Wrong password from server
if (authError) { authError.textContent = errMsg; authError.classList.remove('hidden'); }
// Restore guest state in UI
this.updateProfileUI();
return;
}
if (serverData) {
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
this.updateShopUI();
}
});
if (res.success) {
// Optimistic: close modal immediately (data loads async)
authModal.classList.add('hidden');
if (authUser) authUser.value = '';
if (authPass) authPass.value = '';
this.loadUserData();
this.updateProfileUI();
this.updateRecordDisplay();
} else {
btnAuthSubmit.textContent = 'Войти / Регистрация';
btnAuthSubmit.disabled = false;
if (authError) {
authError.textContent = res.message || 'Ошибка входа';
authError.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
? ``
: ``;
const biomeNames = {
day: '☀️ День',
night: '🌙 Ночь',
pool: '🏊 Бассейн (6 линий)',
fog: '🌫️ Туман (6 линий)',
roof: '🏠 Крыша'
};
html += `
| Место | Игрок | Результат | Дата |
|---|