- Add viewport-fit=cover & mobile slide-out Drawer navigation menu - Add @media screen rules (<=768px and landscape <=540px) - Add Touch Drag & Drop planting and continuous swipe-to-collect - Add Vibration API haptic feedback for planting, collecting, and explosions - Add automated responsive test assertions in test_game.py (242/242 PASS)
2494 lines
91 KiB
JavaScript
2494 lines
91 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.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
|
|
? 'Текущий режим: <strong>Гость</strong>'
|
|
: `Вы вошли как: <strong>${user}</strong>`;
|
|
}
|
|
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 = `🏆 Рекорд: <strong>${this.highScore}</strong> очков | 💰 Монеты: <strong>${this.economy.totalCoins}</strong> | Уровень: <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> • <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 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
|
|
? `<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 += ` • 💎 +${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.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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
};
|