feat: Initial commit Plants vs Zombies Web Edition v2.4
- 5 Biomes: Day, Night, Pool, Fog, Roof (25 Campaign levels) - Minigames: Wall-nut Bowling, I Zombie, Roguelike, Boss Rush & Dr. Zomboss - Systems: Zen Garden, Workshop Crafting, Almanac, Tree of Wisdom, Achievements - Backend: REST API + SQLite Accounts & Save Synchronization - Infrastructure: Nginx HTTPS & Docker deployment
This commit is contained in:
commit
6457a8904e
50 files changed
+14486
No files matched your search
@@ -0,0 +1,271 @@
|
||||
// Achievements & Trophy System for Plants vs. Zombies
|
||||
window.AchievementSystem = class AchievementSystem {
|
||||
constructor(gameEngine) {
|
||||
this.engine = gameEngine;
|
||||
|
||||
this.achievements = [
|
||||
{
|
||||
id: 'first_plant',
|
||||
title: 'Первый росток',
|
||||
desc: 'Посадите любое растение на газоне',
|
||||
icon: '🌱',
|
||||
reward: { coins: 50 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'sun_tycoon',
|
||||
title: 'Солнечный магнат',
|
||||
desc: 'Накопите 1000 солнца в одном матче',
|
||||
icon: '☀️',
|
||||
reward: { coins: 150 },
|
||||
progress: 0,
|
||||
max: 1000,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'nut_wall',
|
||||
title: 'Ореховая стена',
|
||||
desc: 'Выдержите 30 укусов зомби стеноорехом',
|
||||
icon: '🌰',
|
||||
reward: { coins: 100 },
|
||||
progress: 0,
|
||||
max: 30,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'spudow_master',
|
||||
title: 'SPUDOW!',
|
||||
desc: 'Уничтожьте зомби картофельной миной',
|
||||
icon: '🥔',
|
||||
reward: { coins: 120 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'ice_age',
|
||||
title: 'Ледниковый период',
|
||||
desc: 'Замедлите 25 зомби ледяным горохом',
|
||||
icon: '❄️',
|
||||
reward: { coins: 150 },
|
||||
progress: 0,
|
||||
max: 25,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'fire_jalapeno',
|
||||
title: 'Огненный шквал',
|
||||
desc: 'Испепелите дорожку зомби Халапеньо',
|
||||
icon: '🌶️',
|
||||
reward: { coins: 120 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'bowling_strike',
|
||||
title: 'Мастер боулинга',
|
||||
desc: 'Сделайте рикошет орехом в режиме Боулинг',
|
||||
icon: '🎳',
|
||||
reward: { coins: 150 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'izombie_win',
|
||||
title: 'Я, Гений!',
|
||||
desc: 'Одержите победу в режиме «Я, Зомби»',
|
||||
icon: '🧠',
|
||||
reward: { coins: 200 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'tier3_unlocked',
|
||||
title: 'Зеленый инженер',
|
||||
desc: 'Прокачайте любое растение до 3★ в Мастерской',
|
||||
icon: '🧪',
|
||||
reward: { coins: 300, crystals: 1 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'zen_blooming',
|
||||
title: 'Магнат Дзен-Сада',
|
||||
desc: 'Вырастите взрослое цветущее растение в горшке',
|
||||
icon: '🪴',
|
||||
reward: { coins: 200 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'snail_feed',
|
||||
title: 'Сытая улитка',
|
||||
desc: 'Покормите улитку Стинки шоколадом',
|
||||
icon: '🐌',
|
||||
reward: { coins: 100 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'zombie_slayer',
|
||||
title: 'Истребитель нежити',
|
||||
desc: 'Уничтожьте 100 зомби в любых битвах',
|
||||
icon: '⚔️',
|
||||
reward: { coins: 300, crystals: 1 },
|
||||
progress: 0,
|
||||
max: 100,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'clean_lawn',
|
||||
title: 'Идеальная оборона',
|
||||
desc: 'Пройдите уровень без срабатывания газонокосилок',
|
||||
icon: '✨',
|
||||
reward: { coins: 250 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 'roguelike_relic',
|
||||
title: 'Искатель реликвий',
|
||||
desc: 'Выберите реликвию в режиме «Садовый Рогалик»',
|
||||
icon: '🎲',
|
||||
reward: { coins: 200 },
|
||||
progress: 0,
|
||||
max: 1,
|
||||
completed: false,
|
||||
claimed: false
|
||||
}
|
||||
];
|
||||
|
||||
this.loadFromAuth();
|
||||
}
|
||||
|
||||
loadFromAuth() {
|
||||
for (const a of this.achievements) {
|
||||
a.progress = 0;
|
||||
a.completed = false;
|
||||
a.claimed = false;
|
||||
}
|
||||
let loadedData = null;
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
const uData = window.PvZAuth.getUserData();
|
||||
if (uData.achievements) loadedData = uData.achievements;
|
||||
}
|
||||
if (!loadedData) {
|
||||
try {
|
||||
loadedData = JSON.parse(localStorage.getItem('pvz_achievements') || 'null');
|
||||
} catch (e) {}
|
||||
}
|
||||
if (loadedData) {
|
||||
for (const [id, saved] of Object.entries(loadedData)) {
|
||||
const ach = this.achievements.find(a => a.id === id);
|
||||
if (ach) {
|
||||
ach.progress = saved.progress || 0;
|
||||
ach.completed = saved.completed || false;
|
||||
ach.claimed = saved.claimed || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveToAuth() {
|
||||
const map = {};
|
||||
for (const a of this.achievements) {
|
||||
map[a.id] = { progress: a.progress, completed: a.completed, claimed: a.claimed };
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('pvz_achievements', JSON.stringify(map));
|
||||
} catch (e) {}
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
window.PvZAuth.saveUserData({ achievements: map });
|
||||
}
|
||||
}
|
||||
|
||||
trigger(id, amount = 1) {
|
||||
const ach = this.achievements.find(a => a.id === id);
|
||||
if (!ach || ach.completed) return;
|
||||
|
||||
ach.progress = Math.min(ach.progress + amount, ach.max);
|
||||
if (ach.progress >= ach.max) {
|
||||
ach.completed = true;
|
||||
this.showToast(ach);
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
}
|
||||
this.saveToAuth();
|
||||
}
|
||||
|
||||
setProgress(id, val) {
|
||||
const ach = this.achievements.find(a => a.id === id);
|
||||
if (!ach || ach.completed) return;
|
||||
|
||||
ach.progress = Math.max(ach.progress, Math.min(val, ach.max));
|
||||
if (ach.progress >= ach.max) {
|
||||
ach.completed = true;
|
||||
this.showToast(ach);
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
}
|
||||
this.saveToAuth();
|
||||
}
|
||||
|
||||
claimReward(id) {
|
||||
const ach = this.achievements.find(a => a.id === id);
|
||||
if (!ach || !ach.completed || ach.claimed) return false;
|
||||
|
||||
ach.claimed = true;
|
||||
if (ach.reward.coins && this.engine && this.engine.economy) {
|
||||
this.engine.economy.addCoins(ach.reward.coins);
|
||||
}
|
||||
if (ach.reward.crystals && this.engine && this.engine.workshop) {
|
||||
this.engine.workshop.addMaterial('crystals', ach.reward.crystals);
|
||||
}
|
||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
|
||||
showToast(ach) {
|
||||
let toast = document.getElementById('achievement-toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'achievement-toast';
|
||||
toast.className = 'achievement-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="ach-toast-icon">${ach.icon}</div>
|
||||
<div class="ach-toast-text">
|
||||
<div class="ach-toast-subtitle">ДОСТИЖЕНИЕ РАЗБЛОКИРОВАНО!</div>
|
||||
<div class="ach-toast-title">${ach.title}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toast.classList.add('visible');
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('visible');
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
// Suburban Almanac System: Full Encyclopedia of Plants & Zombies
|
||||
window.AlmanacSystem = class AlmanacSystem {
|
||||
constructor() {
|
||||
this.selectedCategory = 'plants'; // 'plants' or 'zombies'
|
||||
this.selectedId = 'peashooter';
|
||||
|
||||
this.plants = [
|
||||
{
|
||||
id: 'peashooter',
|
||||
name: 'Горохострел',
|
||||
icon: '🌱',
|
||||
cost: 100,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: '20 (Нормальный)',
|
||||
toughness: '300 (Обычная)',
|
||||
desc: 'Базовая стрелковая единица газона. Стреляет одиночными горошинами по наступающим зомби.',
|
||||
lore: '«Как можно быть таким популярным и при этом оставаться простым парнем? Спросите у Горохострела. Он скажет: "Просто делай меткие выстрелы!"»'
|
||||
},
|
||||
{
|
||||
id: 'sunflower',
|
||||
name: 'Подсолнух',
|
||||
icon: '🌻',
|
||||
cost: 50,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: 'Нет',
|
||||
toughness: '300 (Обычная)',
|
||||
desc: 'Ключевой источник солнца. Производит 25 единиц солнца каждые 12 секунд.',
|
||||
lore: '«Подсолнух терпеть не может хмурую погоду. Она обожает солнце, позитивную музыку и свежий утренний полив.»'
|
||||
},
|
||||
{
|
||||
id: 'cherrybomb',
|
||||
name: 'Вишневая бомба',
|
||||
icon: '🍒',
|
||||
cost: 150,
|
||||
cooldown: 'Очень долгая (30с)',
|
||||
damage: '1800 (Колоссальный)',
|
||||
toughness: '300',
|
||||
desc: 'Взрывает всех зомби в радиусе 3х3 клетки почти мгновенно.',
|
||||
lore: '«"Мы взорвемся!", — кричит одна вишня. "Нет, МЫ взорвемся!", — спорит вторая. В итоге взрываются обе.»'
|
||||
},
|
||||
{
|
||||
id: 'wallnut',
|
||||
name: 'Стеноорех',
|
||||
icon: '🌰',
|
||||
cost: 50,
|
||||
cooldown: 'Долгая (20с)',
|
||||
damage: 'Нет',
|
||||
toughness: '4000 (Высокая)',
|
||||
desc: 'Надежная преграда. Задерживает зомби на длительное время своей прочной скорлупой.',
|
||||
lore: '«Люди спрашивают Стеноореха: "Каково это — когда тебя все время кусают?". Орех улыбается: "Я просто держу удар и верю в команду".»'
|
||||
},
|
||||
{
|
||||
id: 'potatomine',
|
||||
name: 'Картофельная мина',
|
||||
icon: '🥔',
|
||||
cost: 25,
|
||||
cooldown: 'Долгая (20с)',
|
||||
damage: '1800 (Колоссальный)',
|
||||
toughness: '300',
|
||||
desc: 'Взрывается при контакте с зомби, но требует 14 секунд на взведение под землей.',
|
||||
lore: '«SPUDOW! — любимое слово Картофельной мины. Она не любит долгих разговоров, предпочитая громкий финальный аргумент.»'
|
||||
},
|
||||
{
|
||||
id: 'snowpea',
|
||||
name: 'Ледяной горох',
|
||||
icon: '❄️',
|
||||
cost: 175,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: '20 + Замедление 50%',
|
||||
toughness: '300',
|
||||
desc: 'Стреляет морозными горошинами, которые наносят урон и замедляют скорость ходьбы и кусания зомби.',
|
||||
lore: '«Ледяной горох сохраняет хладнокровие в любой ситуации. Его кумиры — эскимо и северный полюс.»'
|
||||
},
|
||||
{
|
||||
id: 'chomper',
|
||||
name: 'Зубастик',
|
||||
icon: '🌺',
|
||||
cost: 150,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: 'Мгновенное съедение',
|
||||
toughness: '300',
|
||||
desc: 'Проглатывает обычного зомби целиком, после чего пережевывает его в течение 30 секунд.',
|
||||
lore: '«Зубастик побеждал на чемпионате по скоростному поеданию хот-догов. Теперь он перешел на диету из зомби.»'
|
||||
},
|
||||
{
|
||||
id: 'repeater',
|
||||
name: 'Повторитель',
|
||||
icon: '🌿',
|
||||
cost: 200,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: '2x 20 (Двойной)',
|
||||
toughness: '300',
|
||||
desc: 'Выпускает по 2 горошины за каждый залп. Удваивает огневую мощь линии.',
|
||||
lore: '«Повторитель упорно тренировался в тире, чтобы стрелять дуплетом без промаха. И у него это получилось.»'
|
||||
},
|
||||
{
|
||||
id: 'jalapeno',
|
||||
name: 'Халапеньо',
|
||||
icon: '🌶️',
|
||||
cost: 125,
|
||||
cooldown: 'Очень долгая (30с)',
|
||||
damage: '1800 (По всей полосе)',
|
||||
toughness: '300',
|
||||
desc: 'Уничтожает всех зомби на всей горизонтальной линии и растапливает лед.',
|
||||
lore: '«Темперамент Халапеньо взрывоопасен. Достаточно одной искры, чтобы зажечь всю линию газона.»'
|
||||
},
|
||||
{
|
||||
id: 'sunshroom',
|
||||
name: 'Солнцегриб',
|
||||
icon: '🍄',
|
||||
cost: 25,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: 'Нет',
|
||||
toughness: '300',
|
||||
desc: 'Ночное растение. Сначала дает маленькие солнца (15 ☀️), затем вырастает и дает по 25 ☀️.',
|
||||
lore: '«Солнцегриб не любит яркий день, но обожает расти при свете луны.»'
|
||||
},
|
||||
{
|
||||
id: 'fumeshroom',
|
||||
name: 'Дымогриб',
|
||||
icon: '💨',
|
||||
cost: 75,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: '20 (Сквозной урон)',
|
||||
toughness: '300',
|
||||
desc: 'Выстреливает волну спор на 4 клетки вперед, пробивая газеты и экраны зомби.',
|
||||
lore: '«"Мои споры пахнут победой!", — заявляет Дымогриб. Зомби с этим категорически не согласны.»'
|
||||
},
|
||||
{
|
||||
id: 'lilypad',
|
||||
name: 'Кувшинка',
|
||||
icon: '🪷',
|
||||
cost: 25,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: 'Нет',
|
||||
toughness: '300',
|
||||
desc: 'Плавающая платформа для бассейна. Позволяет сажать наземные растения на водные дорожки.',
|
||||
lore: '«Кувшинка держит на плаву всю команду в водных уровнях. Без нее ни один горохострел не поплывет.»'
|
||||
},
|
||||
{
|
||||
id: 'gravebuster',
|
||||
name: 'Могилоед',
|
||||
icon: '🪵',
|
||||
cost: 75,
|
||||
cooldown: 'Быстрая (7.5с)',
|
||||
damage: 'Уничтожает надгробие',
|
||||
toughness: '300',
|
||||
desc: 'Сажается на могильные камни в ночных уровнях и медленно разрушает их, оставляя монету.',
|
||||
lore: '«Несмотря на грозный вид, Могилоед очень нежный и любит, когда ему чешут кору за ушком.»'
|
||||
}
|
||||
];
|
||||
|
||||
this.zombies = [
|
||||
{
|
||||
id: 'z_normal',
|
||||
name: 'Обычный зомби',
|
||||
icon: '🧟',
|
||||
speed: 'Базовая (0.25)',
|
||||
toughness: '200 (Обычная)',
|
||||
desc: 'Рядовой пехотинец армии зомби. Медленно ковыляет вперед в поисках свежих мозгов.',
|
||||
lore: '«Этот зомби любит мозги. Ничего необычного, просто типичный потребитель пригородной недвижимости.»'
|
||||
},
|
||||
{
|
||||
id: 'z_flag',
|
||||
name: 'Зомби с флагом',
|
||||
icon: '🚩',
|
||||
speed: 'Быстрая (0.35)',
|
||||
toughness: '200',
|
||||
desc: 'Ведет за собой огромную волну нечисти. Появление флага означает максимальный натиск.',
|
||||
lore: '«Флаг зомби символизирует его преданность делу поедания газонов. Ни шагу назад!»'
|
||||
},
|
||||
{
|
||||
id: 'z_cone',
|
||||
name: 'Зомби с конусом',
|
||||
icon: '⚠️',
|
||||
speed: 'Базовая (0.25)',
|
||||
toughness: '560 (200 + 360 конус)',
|
||||
desc: 'Дорожный конус на голове удваивает запас его стойкости перед стрелковыми растениями.',
|
||||
lore: '«Конус на голове защищает от дождя, падающих яблок и горошин. Настоящая вершина пригородной инженерии.»'
|
||||
},
|
||||
{
|
||||
id: 'z_bucket',
|
||||
name: 'Зомби с ведром',
|
||||
icon: '🪣',
|
||||
speed: 'Базовая (0.25)',
|
||||
toughness: '1300 (200 + 1100 ведро)',
|
||||
desc: 'Железное ведро делает его исключительно живучим танком. Пробивать его нужно сообща.',
|
||||
lore: '«Ведро на голове принадлежало безумному маляру. Теперь оно служит идеальным шлемом.»'
|
||||
},
|
||||
{
|
||||
id: 'z_pole',
|
||||
name: 'Зомби с шестом',
|
||||
icon: '🏃',
|
||||
speed: 'Высокая бег (0.55)',
|
||||
toughness: '340',
|
||||
desc: 'Быстро бежит с шестом и перепрыгивает через первое встреченное растение.',
|
||||
lore: '«Бывший школьный чемпион по прыжкам с шестом. Сохранил форму и прыгучесть даже после зомби-апокалипсиса.»'
|
||||
},
|
||||
{
|
||||
id: 'z_newspaper',
|
||||
name: 'Зомби с газетой',
|
||||
icon: '📰',
|
||||
speed: '0.25 ➔ 0.65 в ярости',
|
||||
toughness: '350 (200 + 150 газета)',
|
||||
desc: 'Газета дает временный щит. Когда газета разрушается — впадает в безумную ярость и несется вперед.',
|
||||
lore: '«Он почти закончил разгадывать утренний судоку. Вы испортили его газету — теперь берегитесь!»'
|
||||
},
|
||||
{
|
||||
id: 'z_football',
|
||||
name: 'Зомби-футболист',
|
||||
icon: '🏈',
|
||||
speed: 'Очень высокая (0.48)',
|
||||
toughness: '1600 (Тяжелая броня)',
|
||||
desc: 'Тяжелый американский футболист. Быстро преодолевает дистанцию и выдерживает колоссальный урон.',
|
||||
lore: '«Играет в высшей зомби-лиге. Тренер говорил ему: "Всегда пробивай оборону до конца!". Он помнит этот наказ.»'
|
||||
},
|
||||
{
|
||||
id: 'z_yeti',
|
||||
name: 'Секретный Зомби-Йети',
|
||||
icon: '❄️🧟',
|
||||
speed: 'Переменная (0.35)',
|
||||
toughness: '1350 (Очень высокая)',
|
||||
desc: 'Легендарное редкое существо. При получении половины урона разворачивается и пытается убежать! Сбрасывает 4 алмаза (800 монет).',
|
||||
lore: '«О нем слагают мифы в снежных горах. Мало кто видел Йети лично, и еще меньше тех, кто успел отобрать у него алмазы до того, как он скрылся.»'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getItem(id) {
|
||||
return this.plants.find(p => p.id === id) || this.zombies.find(z => z.id === id);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
// Auth & Profile Management System — Server-backed (SQLite via /api/auth/)
|
||||
// Falls back to localStorage if server is unavailable.
|
||||
window.PvZAuth = (function () {
|
||||
const CURRENT_USER_KEY = 'pvz_current_user';
|
||||
const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
|
||||
const API_BASE = '/api/auth';
|
||||
|
||||
/* ─── Simple djb2 hash (same as before, for backward-compat) ─── */
|
||||
function hashPassword(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = ((h << 5) - h) + str.charCodeAt(i);
|
||||
h |= 0;
|
||||
}
|
||||
return h.toString(16);
|
||||
}
|
||||
|
||||
/* ─── Default blank profile ─── */
|
||||
function getDefaultData() {
|
||||
return {
|
||||
coins: 150,
|
||||
level: 1,
|
||||
maxUnlockedLevel: 1,
|
||||
highScore: 0,
|
||||
theme: 'classic',
|
||||
upgrades: { extraSlot: false, hasRake: false },
|
||||
materials: { bioHumus: 10, scrapMetal: 5, sunEssence: 20, crystals: 0 },
|
||||
plantLevels: { peashooter: 1, sunflower: 1, wallnut: 1, potatomine: 1,
|
||||
repeater: 1, snowpea: 1, chomper: 1 },
|
||||
perks: { magnetism: 0, startSun: 0, fastCool: 0, hardyRoots: 0, luckyDrop: 0 },
|
||||
zenGarden: {
|
||||
pots: [
|
||||
{ id: 0, plantType: 'sunflower', stage: 2, thirstTimer: 0, needsFertilizer: false, happyTimer: 60, coinTimer: 5 },
|
||||
{ id: 1, plantType: 'peashooter', stage: 1, thirstTimer: 0, needsFertilizer: false, happyTimer: 45, coinTimer: 8 },
|
||||
{ id: 2, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 },
|
||||
{ id: 3, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 },
|
||||
{ id: 4, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 },
|
||||
{ id: 5, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 }
|
||||
],
|
||||
fertilizerCount: 5,
|
||||
chocolateCount: 2,
|
||||
snail: { x: 100, targetX: 300, dir: 1, isAwake: true, sleepTimer: 120, boostTimer: 0 },
|
||||
lastVisit: Date.now()
|
||||
},
|
||||
treeOfWisdom: { height: 0, cheats: {} },
|
||||
achievements: {}
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── In-memory current user data ─── */
|
||||
let _cachedData = null;
|
||||
|
||||
function localSave(data) {
|
||||
try { localStorage.setItem(LOCAL_DATA_KEY, JSON.stringify(data)); } catch (_) {}
|
||||
}
|
||||
function localLoad() {
|
||||
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/* ─── Non-blocking server call ─── */
|
||||
async function apiPost(endpoint, body) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
return { success: false, message: 'Сервер недоступен' };
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Debounced save (prevents save storms on rapid updates) ─── */
|
||||
let _saveTimer = null;
|
||||
function scheduleSave() {
|
||||
if (_saveTimer) clearTimeout(_saveTimer);
|
||||
_saveTimer = setTimeout(() => {
|
||||
const user = localStorage.getItem(CURRENT_USER_KEY);
|
||||
if (!user || user === 'Гость' || !_cachedData) return;
|
||||
apiPost('save', { username: user, data: _cachedData });
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
return {
|
||||
/* ── Public API ── */
|
||||
|
||||
getCurrentUser() {
|
||||
return localStorage.getItem(CURRENT_USER_KEY) || 'Гость';
|
||||
},
|
||||
|
||||
isGuest() {
|
||||
const u = localStorage.getItem(CURRENT_USER_KEY);
|
||||
return !u || u === 'Гость';
|
||||
},
|
||||
|
||||
getDefaultData,
|
||||
|
||||
/**
|
||||
* Login or auto-register.
|
||||
* Synchronous return with provisional localStorage data;
|
||||
* server response loaded asynchronously and applied via callback.
|
||||
*/
|
||||
login(username, password, onServerResponse = null) {
|
||||
username = (username || '').trim();
|
||||
if (!username) return { success: false, message: 'Введите имя пользователя' };
|
||||
if (username.toLowerCase() === 'гость') {
|
||||
this.loginAsGuest();
|
||||
return { success: true, user: 'Гость' };
|
||||
}
|
||||
|
||||
const pwdHash = hashPassword(password);
|
||||
|
||||
// Optimistically set local session
|
||||
localStorage.setItem(CURRENT_USER_KEY, username);
|
||||
_cachedData = getDefaultData();
|
||||
localSave(_cachedData);
|
||||
|
||||
// Async server auth
|
||||
apiPost('login', { username, passwordHash: pwdHash }).then(res => {
|
||||
if (res && res.success && res.data) {
|
||||
// Merge defaults + server data
|
||||
const def = getDefaultData();
|
||||
_cachedData = {
|
||||
...def, ...res.data,
|
||||
upgrades: { ...def.upgrades, ...(res.data.upgrades || {}) },
|
||||
materials: { ...def.materials, ...(res.data.materials || {}) },
|
||||
plantLevels: { ...def.plantLevels, ...(res.data.plantLevels || {}) },
|
||||
perks: { ...def.perks, ...(res.data.perks || {}) },
|
||||
zenGarden: res.data.zenGarden
|
||||
? { ...def.zenGarden, ...res.data.zenGarden,
|
||||
pots: res.data.zenGarden.pots || def.zenGarden.pots,
|
||||
snail: { ...def.zenGarden.snail, ...(res.data.zenGarden.snail || {}) }
|
||||
}
|
||||
: def.zenGarden,
|
||||
treeOfWisdom: res.data.treeOfWisdom || def.treeOfWisdom,
|
||||
achievements: res.data.achievements || {}
|
||||
};
|
||||
localSave(_cachedData);
|
||||
if (typeof onServerResponse === 'function') onServerResponse(_cachedData);
|
||||
// Notify game engine to reload profile
|
||||
if (window.GameEngine && window._pvzEngine) {
|
||||
window._pvzEngine.loadUserData();
|
||||
}
|
||||
} else if (res && !res.success) {
|
||||
// Wrong password — kick back to guest
|
||||
if (res.message && res.message.includes('пароль')) {
|
||||
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
||||
_cachedData = null;
|
||||
if (typeof onServerResponse === 'function') onServerResponse(null, res.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, user: username, pending: true };
|
||||
},
|
||||
|
||||
loginAsGuest() {
|
||||
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
||||
_cachedData = null;
|
||||
localSave(null);
|
||||
return { success: true, user: 'Гость' };
|
||||
},
|
||||
|
||||
logout() {
|
||||
// Flush any pending save first
|
||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||
const user = this.getCurrentUser();
|
||||
if (user !== 'Гость' && _cachedData) {
|
||||
apiPost('save', { username: user, data: _cachedData });
|
||||
}
|
||||
this.loginAsGuest();
|
||||
},
|
||||
|
||||
getUserData() {
|
||||
if (this.isGuest()) return getDefaultData();
|
||||
|
||||
if (_cachedData) return _cachedData;
|
||||
|
||||
// Try local cache (sync fallback while server loads)
|
||||
const local = localLoad();
|
||||
if (local) {
|
||||
_cachedData = local;
|
||||
return local;
|
||||
}
|
||||
|
||||
_cachedData = getDefaultData();
|
||||
return _cachedData;
|
||||
},
|
||||
|
||||
saveUserData(data) {
|
||||
if (this.isGuest()) return; // Guests are not persisted
|
||||
|
||||
_cachedData = _cachedData
|
||||
? { ..._cachedData, ...data }
|
||||
: { ...getDefaultData(), ...data };
|
||||
|
||||
localSave(_cachedData); // instant local write
|
||||
scheduleSave(); // debounced server sync
|
||||
},
|
||||
|
||||
/** Force immediate server save (call on game-over / victory) */
|
||||
flushSave() {
|
||||
if (this.isGuest() || !_cachedData) return;
|
||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||
apiPost('save', { username: this.getCurrentUser(), data: _cachedData });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,377 @@
|
||||
// Dr. Zomboss Multi-Phase Boss Fight & Zombot Mechanics
|
||||
window.BossSystem = class BossSystem {
|
||||
constructor(gameEngine) {
|
||||
this.engine = gameEngine;
|
||||
this.isActive = false;
|
||||
this.maxHealth = 6000;
|
||||
this.health = 6000;
|
||||
|
||||
// Position & Visuals
|
||||
this.x = 670;
|
||||
this.y = 140;
|
||||
this.w = 210;
|
||||
this.h = 340;
|
||||
this.animTimer = 0;
|
||||
this.eyeColor = '#00e5ff';
|
||||
|
||||
// Attacks & Timers
|
||||
this.stompTimer = 8.0;
|
||||
this.ballTimer = 14.0;
|
||||
this.vanTimer = 22.0;
|
||||
this.summonTimer = 10.0;
|
||||
|
||||
// Active boss projectiles (Ice balls, Fire balls, RV vans)
|
||||
this.hazards = [];
|
||||
|
||||
// Stomp animation
|
||||
this.isStomping = false;
|
||||
this.stompTarget = null;
|
||||
this.stompAnimProgress = 0;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.isActive = false;
|
||||
this.health = this.maxHealth;
|
||||
this.stompTimer = 8.0;
|
||||
this.ballTimer = 14.0;
|
||||
this.vanTimer = 22.0;
|
||||
this.summonTimer = 10.0;
|
||||
this.hazards = [];
|
||||
this.isStomping = false;
|
||||
this.stompTarget = null;
|
||||
}
|
||||
|
||||
startFight() {
|
||||
this.isActive = true;
|
||||
this.health = this.maxHealth;
|
||||
this.hazards = [];
|
||||
this.stompTimer = 6.0;
|
||||
this.ballTimer = 12.0;
|
||||
this.vanTimer = 25.0;
|
||||
this.summonTimer = 8.0;
|
||||
}
|
||||
|
||||
takeDamage(amount) {
|
||||
if (!this.isActive || this.health <= 0) return;
|
||||
this.health = Math.max(0, this.health - amount);
|
||||
|
||||
if (this.health <= 0) {
|
||||
this.defeatBoss();
|
||||
}
|
||||
}
|
||||
|
||||
defeatBoss() {
|
||||
this.isActive = false;
|
||||
if (window.PvZAudio) {
|
||||
window.PvZAudio.playExplosion();
|
||||
window.PvZAudio.playWin();
|
||||
}
|
||||
|
||||
// Huge reward
|
||||
if (this.engine && this.engine.economy) {
|
||||
this.engine.economy.addCoins(1000);
|
||||
}
|
||||
if (this.engine && this.engine.workshop) {
|
||||
this.engine.workshop.addMaterial('crystals', 5);
|
||||
this.engine.workshop.addMaterial('scrapMetal', 20);
|
||||
}
|
||||
if (this.engine && this.engine.leaderboards) {
|
||||
this.engine.leaderboards.submitScore('boss', 1, 'Побежден Зомбот!');
|
||||
}
|
||||
|
||||
// Explosion celebration
|
||||
for (let i = 0; i < 60; i++) {
|
||||
setTimeout(() => {
|
||||
if (this.engine && this.engine.combat) {
|
||||
const rx = this.x + Math.random() * this.w;
|
||||
const ry = this.y + Math.random() * this.h;
|
||||
this.engine.combat.createExplosionParticles(rx, ry);
|
||||
}
|
||||
}, i * 50);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.engine) {
|
||||
this.engine.triggerVictory();
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
if (!this.isActive) return;
|
||||
|
||||
this.animTimer += dt;
|
||||
|
||||
// Phase detection
|
||||
const hpRatio = this.health / this.maxHealth;
|
||||
const speedMult = hpRatio < 0.35 ? 1.5 : (hpRatio < 0.70 ? 1.25 : 1.0);
|
||||
this.eyeColor = hpRatio < 0.35 ? '#ff1744' : (hpRatio < 0.70 ? '#ff9800' : '#00e5ff');
|
||||
|
||||
// 1. Attack: Stomp (Phase 1+)
|
||||
this.stompTimer -= dt * speedMult;
|
||||
if (this.stompTimer <= 0) {
|
||||
this.stompTimer = 14.0;
|
||||
this.triggerStomp(gameState);
|
||||
}
|
||||
|
||||
// 2. Attack: Ice Ball / Fire Ball (Phase 2+)
|
||||
if (hpRatio < 0.75) {
|
||||
this.ballTimer -= dt * speedMult;
|
||||
if (this.ballTimer <= 0) {
|
||||
this.ballTimer = 16.0;
|
||||
const ballType = Math.random() < 0.5 ? 'ice' : 'fire';
|
||||
const row = Math.floor(Math.random() * gameState.grid.rows);
|
||||
this.spawnBallHazard(row, ballType, gameState);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Attack: Van Throw (Phase 3 Enraged)
|
||||
if (hpRatio < 0.35) {
|
||||
this.vanTimer -= dt * speedMult;
|
||||
if (this.vanTimer <= 0) {
|
||||
this.vanTimer = 22.0;
|
||||
this.throwVan(gameState);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Summons
|
||||
this.summonTimer -= dt * speedMult;
|
||||
if (this.summonTimer <= 0) {
|
||||
this.summonTimer = 12.0;
|
||||
const count = hpRatio < 0.35 ? 3 : 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['conehead', 'buckethead', 'football', 'bungee', 'normal'];
|
||||
const t = types[Math.floor(Math.random() * types.length)];
|
||||
const r = Math.floor(Math.random() * gameState.grid.rows);
|
||||
gameState.spawnZombie(r, t === 'bungee' ? 'buckethead' : t); // Bungee drop drops a durable zombie
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update Hazards
|
||||
for (const h of this.hazards) {
|
||||
if (h.type === 'ice' || h.type === 'fire') {
|
||||
h.x -= h.speed * dt;
|
||||
|
||||
// Crush plants in path
|
||||
for (const p of gameState.plants) {
|
||||
if (p.row === h.row && Math.abs(p.x - h.x) < 30 && !p.toRemove) {
|
||||
p.toRemove = true;
|
||||
gameState.grid.removePlant(p.col, p.row);
|
||||
gameState.combat.createSplash(p.x, p.y, h.type === 'ice' ? '#80deea' : '#ff5722');
|
||||
}
|
||||
}
|
||||
|
||||
// Jalapeno melts ice ball!
|
||||
if (h.type === 'ice') {
|
||||
for (const p of gameState.plants) {
|
||||
if (p.type === 'jalapeno' && p.row === h.row && Math.abs(p.x - h.x) < 50) {
|
||||
h.toRemove = true;
|
||||
gameState.combat.createExplosionParticles(h.x, h.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snowpea / Ice slows and extinguishes fireball
|
||||
if (h.type === 'fire') {
|
||||
for (const proj of gameState.projectiles) {
|
||||
if (proj.row === h.row && proj.slows && Math.abs(proj.x - h.x) < 30) {
|
||||
h.toRemove = true;
|
||||
proj.toRemove = true;
|
||||
gameState.combat.createSplash(h.x, h.y, '#80deea', 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (h.x < 30) {
|
||||
h.toRemove = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.hazards = this.hazards.filter(h => !h.toRemove);
|
||||
}
|
||||
|
||||
triggerStomp(gameState) {
|
||||
const alivePlants = gameState.plants.filter(p => !p.toRemove);
|
||||
if (alivePlants.length === 0) return;
|
||||
|
||||
const victim = alivePlants[Math.floor(Math.random() * alivePlants.length)];
|
||||
if (victim) {
|
||||
victim.toRemove = true;
|
||||
gameState.grid.removePlant(victim.col, victim.row);
|
||||
gameState.combat.createExplosionParticles(victim.x, victim.y);
|
||||
if (window.PvZAudio) window.PvZAudio.playStrike();
|
||||
}
|
||||
}
|
||||
|
||||
spawnBallHazard(row, type, gameState) {
|
||||
const bounds = gameState.grid.getCellBounds(7, row);
|
||||
this.hazards.push({
|
||||
type,
|
||||
row,
|
||||
x: this.x - 20,
|
||||
y: bounds.centerY,
|
||||
speed: 130,
|
||||
radius: 28,
|
||||
toRemove: false
|
||||
});
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
}
|
||||
|
||||
throwVan(gameState) {
|
||||
// Pick 2x2 area to crush
|
||||
const targetCol = 2 + Math.floor(Math.random() * 3);
|
||||
const targetRow = Math.floor(Math.random() * (gameState.grid.rows - 1));
|
||||
|
||||
for (let r = targetRow; r <= targetRow + 1; r++) {
|
||||
for (let c = targetCol; c <= targetCol + 1; c++) {
|
||||
const p = gameState.grid.getPlant(c, r);
|
||||
if (p) {
|
||||
p.toRemove = true;
|
||||
gameState.grid.removePlant(c, r);
|
||||
}
|
||||
const b = gameState.grid.getCellBounds(c, r);
|
||||
gameState.combat.createExplosionParticles(b.centerX, b.centerY);
|
||||
}
|
||||
}
|
||||
if (window.PvZAudio) window.PvZAudio.playExplosion();
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
if (!this.isActive) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// 1. Zombot Body & Legs
|
||||
const bob = Math.sin(this.animTimer * 2.5) * 5;
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(this.x + 95, this.y + 320, 90, 24, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Robot Legs
|
||||
ctx.fillStyle = '#424242';
|
||||
ctx.fillRect(this.x + 30, this.y + 180 + bob, 35, 140);
|
||||
ctx.fillRect(this.x + 125, this.y + 180 + bob, 35, 140);
|
||||
|
||||
// Feet
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.fillRect(this.x + 15, this.y + 300, 65, 24);
|
||||
ctx.fillRect(this.x + 110, this.y + 300, 65, 24);
|
||||
|
||||
// Torso / Chassis
|
||||
ctx.fillStyle = '#616161';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(this.x + 15, this.y + 70 + bob, 160, 130, 16);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#212121';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.stroke();
|
||||
|
||||
// Cockpit Glass & Dr. Zomboss
|
||||
ctx.fillStyle = 'rgba(33, 150, 243, 0.35)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(this.x + 40, this.y + 15 + bob, 110, 65, 12);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#0d47a1';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Dr. Zomboss head inside
|
||||
ctx.fillStyle = '#81c784'; // zombie green skin
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x + 95, this.y + 45 + bob, 22, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Zomboss giant brain
|
||||
ctx.fillStyle = '#f48fb1';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x + 95, this.y + 34 + bob, 16, Math.PI, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Zomboss red eyes
|
||||
ctx.fillStyle = '#d50000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x + 88, this.y + 45 + bob, 4, 0, Math.PI * 2);
|
||||
ctx.arc(this.x + 102, this.y + 45 + bob, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Zombot Giant Robot Eyes
|
||||
ctx.fillStyle = this.eyeColor;
|
||||
ctx.shadowColor = this.eyeColor;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x + 60, this.y + 115 + bob, 14, 0, Math.PI * 2);
|
||||
ctx.arc(this.x + 130, this.y + 115 + bob, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Zombot Metal Jaw & Grille
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.fillRect(this.x + 45, this.y + 150 + bob, 100, 30);
|
||||
ctx.strokeStyle = '#757575';
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.x + 52 + i * 16, this.y + 150 + bob);
|
||||
ctx.lineTo(this.x + 52 + i * 16, this.y + 180 + bob);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 2. Boss Health Bar (Top Center)
|
||||
const barW = 340;
|
||||
const barH = 22;
|
||||
const barX = 280;
|
||||
const barY = 16;
|
||||
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX, barY, barW, barH, 5);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#b71c1c';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
|
||||
const hpRatioBar = Math.max(0, this.health / this.maxHealth);
|
||||
ctx.fillStyle = hpRatioBar < 0.35 ? '#d50000' : (hpRatioBar < 0.70 ? '#ff9800' : '#4caf50');
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX + 2, barY + 2, Math.max(0, (barW - 4) * hpRatioBar), barH - 4, 3);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`👑 ДОКТОР ЗОМБОСС: ${Math.round(this.health)} / ${this.maxHealth} HP`, barX + barW / 2, barY + 15);
|
||||
|
||||
// 3. Draw Active Hazards (Ice/Fire Balls)
|
||||
for (const h of this.hazards) {
|
||||
ctx.save();
|
||||
ctx.translate(h.x, h.y);
|
||||
if (h.type === 'ice') {
|
||||
ctx.fillStyle = '#80deea';
|
||||
ctx.shadowColor = '#00e5ff';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, h.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.fillStyle = '#ff5722';
|
||||
ctx.shadowColor = '#ff1744';
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, h.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffeb3b';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// Campaign Worlds & 25 Levels Definition System
|
||||
window.CampaignSystem = (function() {
|
||||
const WORLDS = [
|
||||
{ id: 1, name: 'Солнечный День', icon: '☀️', biome: 'day', range: '1-1 .. 1-5' },
|
||||
{ id: 2, name: 'Ночной Двор', icon: '🌙', biome: 'night', range: '2-1 .. 2-5' },
|
||||
{ id: 3, name: 'Бассейн', icon: '🏊', biome: 'pool', range: '3-1 .. 3-5' },
|
||||
{ id: 4, name: 'Ночной Туман', icon: '🌫️', biome: 'fog', range: '4-1 .. 4-5' },
|
||||
{ id: 5, name: 'Крыша Дома', icon: '🏠', biome: 'roof', range: '5-1 .. 5-5' }
|
||||
];
|
||||
|
||||
const LEVELS = [
|
||||
// World 1: Day (1-1 .. 1-5)
|
||||
{ level: 1, world: 1, stage: 1, code: '1-1', title: 'Первые шаги', desc: 'Знакомство с садом и простыми зомби', waves: 3, biome: 'day', reward: 100 },
|
||||
{ level: 2, world: 1, stage: 2, code: '1-2', title: 'Опасные конусы', desc: 'Появление стойких зомби с дорожными конусами', waves: 3, biome: 'day', reward: 120 },
|
||||
{ level: 3, world: 1, stage: 3, code: '1-3', title: 'Картофельный рубеж', desc: 'Тактическая оборона с картофельными минами', waves: 4, biome: 'day', reward: 140 },
|
||||
{ level: 4, world: 1, stage: 4, code: '1-4', title: 'Прыгуны с шестом', desc: 'Быстрые зомби легко перепрыгивают первые растения', waves: 4, biome: 'day', reward: 160 },
|
||||
{ level: 5, world: 1, stage: 5, code: '1-5', title: 'Штурм Дня', desc: 'Финальная массивная волна зомби на солнечном газоне', waves: 4, biome: 'day', reward: 200 },
|
||||
|
||||
// World 2: Night (2-1 .. 2-5)
|
||||
{ level: 6, world: 2, stage: 1, code: '2-1', title: 'Сгущаются сумерки', desc: 'Ночь без падающего солнца: солнцегрибы и надгробия', waves: 3, biome: 'night', reward: 150 },
|
||||
{ level: 7, world: 2, stage: 2, code: '2-2', title: 'Ярость газетчика', desc: 'Разъярённые зомби с газетами ускоряются в бешенстве', waves: 4, biome: 'night', reward: 170 },
|
||||
{ level: 8, world: 2, stage: 3, code: '2-3', title: 'Споровый шквал', desc: 'Дымогрибы пробивают плотные ряды врагов', waves: 4, biome: 'night', reward: 190 },
|
||||
{ level: 9, world: 2, stage: 4, code: '2-4', title: 'Бронекаски', desc: 'Зомби с вёдрами штурмуют тёмный двор', waves: 4, biome: 'night', reward: 210 },
|
||||
{ level: 10, world: 2, stage: 5, code: '2-5', title: 'Полночный кошмар', desc: 'Зомби восстают прямо из могил в финальной волне', waves: 5, biome: 'night', reward: 250 },
|
||||
|
||||
// World 3: Pool (3-1 .. 3-5)
|
||||
{ level: 11, world: 3, stage: 1, code: '3-1', title: 'Водная гладь', desc: '6 дорожек газона, кувшинки и зомби на утках', waves: 3, biome: 'pool', reward: 200 },
|
||||
{ level: 12, world: 3, stage: 2, code: '3-2', title: 'Заплыв с шестом', desc: 'Прыгуны на суше и быстрые пловцы на воде', waves: 4, biome: 'pool', reward: 220 },
|
||||
{ level: 13, world: 3, stage: 3, code: '3-3', title: 'Зубастики у воды', desc: 'Хищные зубастики против бронированных уток', waves: 4, biome: 'pool', reward: 240 },
|
||||
{ level: 14, world: 3, stage: 4, code: '3-4', title: 'Футбольный матч', desc: 'Мощные футболисты прорывают береговую линию', waves: 4, biome: 'pool', reward: 260 },
|
||||
{ level: 15, world: 3, stage: 5, code: '3-5', title: 'Водный шквал', desc: 'Штурм всех 6 дорожек с очистителями бассейна', waves: 5, biome: 'pool', reward: 300 },
|
||||
|
||||
// World 4: Fog (4-1 .. 4-5)
|
||||
{ level: 16, world: 4, stage: 1, code: '4-1', title: 'Плотная мгла', desc: 'Густой туман скрывает правую половину поля', waves: 4, biome: 'fog', reward: 250 },
|
||||
{ level: 17, world: 4, stage: 2, code: '4-2', title: 'Невидимая угроза', desc: 'Внезапные появления врагов прямо из белой пелены', waves: 4, biome: 'fog', reward: 270 },
|
||||
{ level: 18, world: 4, stage: 3, code: '4-3', title: 'Туманный бассейн', desc: 'Водные линии скрыты во мраке ночного тумана', waves: 4, biome: 'fog', reward: 290 },
|
||||
{ level: 19, world: 4, stage: 4, code: '4-4', title: 'Спринт сквозь мглу', desc: 'Футболисты и прыгуны выскакивают из тумана', waves: 5, biome: 'fog', reward: 310 },
|
||||
{ level: 20, world: 4, stage: 5, code: '4-5', title: 'Штормовой туман', desc: 'Финальный прорыв орды через непроглядную пелену', waves: 5, biome: 'fog', reward: 350 },
|
||||
|
||||
// World 5: Roof (5-1 .. 5-5)
|
||||
{ level: 21, world: 5, stage: 1, code: '5-1', title: 'Черепичный скат', desc: 'Крыша дома, цветочные горшки и капустопульты', waves: 4, biome: 'roof', reward: 300 },
|
||||
{ level: 22, world: 5, stage: 2, code: '5-2', title: 'Арбузный гром', desc: 'Тяжёлые арбузопульты со сплэш-уроном по площади', waves: 4, biome: 'roof', reward: 320 },
|
||||
{ level: 23, world: 5, stage: 3, code: '5-3', title: 'Опасные верхолазы', desc: 'Бронезомби карабкаются по скользкому скату крыши', waves: 4, biome: 'roof', reward: 350 },
|
||||
{ level: 24, world: 5, stage: 4, code: '5-4', title: 'Осада дымохода', desc: 'Генеральный штурм перед чердаком дома', waves: 5, biome: 'roof', reward: 400 },
|
||||
{ level: 25, world: 5, stage: 5, code: '5-5', title: 'Доктор Зомбосс!', desc: 'Финальная битва против шагающего робота Zombot!', waves: 1, biome: 'roof', isBoss: true, reward: 1000 }
|
||||
];
|
||||
|
||||
return {
|
||||
WORLDS,
|
||||
LEVELS,
|
||||
getLevel(levelNum) {
|
||||
const num = parseInt(levelNum, 10) || 1;
|
||||
return LEVELS.find(l => l.level === num) || LEVELS[0];
|
||||
},
|
||||
getWorld(worldId) {
|
||||
return WORLDS.find(w => w.id === worldId) || WORLDS[0];
|
||||
},
|
||||
getLevelsByWorld(worldId) {
|
||||
return LEVELS.filter(l => l.world === worldId);
|
||||
},
|
||||
formatLevel(levelNum) {
|
||||
const item = this.getLevel(levelNum);
|
||||
const world = this.getWorld(item.world);
|
||||
return {
|
||||
...item,
|
||||
worldName: world.name,
|
||||
worldIcon: world.icon,
|
||||
fullLabel: `${item.code} (${world.icon} ${world.name})`
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,310 @@
|
||||
// Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops)
|
||||
window.CombatSystem = class CombatSystem {
|
||||
constructor() {
|
||||
this.particles = [];
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.particles = [];
|
||||
}
|
||||
|
||||
createSplash(x, y, color = '#8bc34a', count = 7) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 40 + Math.random() * 80;
|
||||
this.particles.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
color,
|
||||
size: 3 + Math.random() * 3,
|
||||
life: 0.35,
|
||||
maxLife: 0.35
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createExplosionParticles(x, y) {
|
||||
for (let i = 0; i < 35; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 50 + Math.random() * 200;
|
||||
const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242'];
|
||||
this.particles.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
size: 5 + Math.random() * 8,
|
||||
life: 0.65,
|
||||
maxLife: 0.65
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
// 1. Update Particles
|
||||
for (const p of this.particles) {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.life -= dt;
|
||||
}
|
||||
this.particles = this.particles.filter(p => p.life > 0);
|
||||
|
||||
// 2. Projectiles vs Zombies
|
||||
for (const proj of gameState.projectiles) {
|
||||
if (proj.toRemove) continue;
|
||||
|
||||
for (const zombie of gameState.zombies) {
|
||||
if (zombie.toRemove || zombie.health <= 0) continue;
|
||||
|
||||
// Check lane alignment
|
||||
const isSameLane = proj.type === 'bowling'
|
||||
? Math.abs(proj.y - zombie.y) < 45
|
||||
: proj.row === zombie.row;
|
||||
|
||||
if (isSameLane) {
|
||||
if (proj.x >= zombie.x - 28 && proj.x <= zombie.x + 28) {
|
||||
// Piercing fume spore handles each zombie once
|
||||
if (proj.pierces) {
|
||||
if (!proj.pierceHits.has(zombie)) {
|
||||
proj.pierceHits.add(zombie);
|
||||
zombie.takeDamage(proj.damage, false);
|
||||
this.createSplash(proj.x, proj.y, '#ce93d8');
|
||||
if (zombie.health <= 0) this.handleZombieDeath(zombie, gameState);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bowling Ball logic (Wall-nut strike & ricochet)
|
||||
if (proj.type === 'bowling') {
|
||||
zombie.takeDamage(proj.damage);
|
||||
this.createSplash(proj.x, proj.y, '#8d6e63', 12);
|
||||
if (window.PvZAudio) window.PvZAudio.playStrike();
|
||||
|
||||
proj.hitCount = (proj.hitCount || 0) + 1;
|
||||
if (proj.hitCount >= 5 && gameState.achievements) {
|
||||
gameState.achievements.trigger('bowling_strike');
|
||||
}
|
||||
|
||||
if (zombie.health <= 0) this.handleZombieDeath(zombie, gameState);
|
||||
|
||||
// Ricochet vertically
|
||||
if (proj.vy === 0) {
|
||||
proj.vy = (proj.y < 300) ? 180 : -180;
|
||||
} else {
|
||||
proj.vy = -proj.vy;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Lobbed Cabbage & Melon
|
||||
if (proj.isLobbed) {
|
||||
zombie.takeDamage(proj.damage);
|
||||
this.createSplash(proj.x, proj.y, proj.type === 'melon' ? '#2e7d32' : '#81c784', 12);
|
||||
if (window.PvZAudio) window.PvZAudio.playHit();
|
||||
if (zombie.health <= 0) this.handleZombieDeath(zombie, gameState);
|
||||
|
||||
// Melon splash damage to adjacent rows
|
||||
if (proj.splashDamage > 0) {
|
||||
for (const otherZ of gameState.zombies) {
|
||||
if (otherZ !== zombie && !otherZ.toRemove && otherZ.health > 0) {
|
||||
if (Math.abs(otherZ.row - proj.row) <= 1 && Math.abs(otherZ.x - proj.x) < 65) {
|
||||
otherZ.takeDamage(proj.splashDamage);
|
||||
if (otherZ.health <= 0) this.handleZombieDeath(otherZ, gameState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proj.toRemove = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Standard Pea / Snowpea / Plasma / Firepea
|
||||
zombie.takeDamage(proj.damage, proj.slows);
|
||||
|
||||
if (proj.slows && gameState && gameState.achievements) {
|
||||
gameState.achievements.trigger('ice_age');
|
||||
}
|
||||
|
||||
if (proj.freezes) {
|
||||
zombie.slowTimer = 7.0; // Deep freeze
|
||||
}
|
||||
|
||||
if (proj.pierces) {
|
||||
proj.pierceHits.add(zombie);
|
||||
if (proj.pierceHits.size >= proj.maxPierces) {
|
||||
proj.toRemove = true;
|
||||
}
|
||||
} else {
|
||||
proj.toRemove = true;
|
||||
}
|
||||
|
||||
// Firepea splash damage
|
||||
if (proj.type === 'firepea') {
|
||||
for (const otherZ of gameState.zombies) {
|
||||
if (otherZ !== zombie && otherZ.row === proj.row && Math.abs(otherZ.x - proj.x) < 55) {
|
||||
otherZ.takeDamage(proj.damage * 0.5);
|
||||
if (otherZ.health <= 0) this.handleZombieDeath(otherZ, gameState);
|
||||
}
|
||||
}
|
||||
this.createExplosionParticles(proj.x, proj.y);
|
||||
}
|
||||
|
||||
if (zombie.health <= 0) {
|
||||
this.handleZombieDeath(zombie, gameState);
|
||||
}
|
||||
|
||||
const splashColor = proj.type === 'plasma' ? '#00e5ff' : (proj.type === 'firepea' ? '#ff9800' : (proj.slows ? '#80deea' : '#8bc34a'));
|
||||
this.createSplash(proj.x, proj.y, splashColor);
|
||||
|
||||
if (window.PvZAudio) window.PvZAudio.playHit();
|
||||
if (!proj.pierces || proj.toRemove) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Zombies vs Plants
|
||||
for (const zombie of gameState.zombies) {
|
||||
if (zombie.toRemove || zombie.health <= 0) continue;
|
||||
|
||||
let touchingPlant = null;
|
||||
for (const plant of gameState.plants) {
|
||||
if (plant.toRemove || plant.health <= 0) continue;
|
||||
|
||||
if (plant.row === zombie.row) {
|
||||
const dist = zombie.x - plant.x;
|
||||
if (dist >= -5 && dist <= 28) {
|
||||
touchingPlant = plant;
|
||||
// Tier 3 Wall-nut recoil thorns damage!
|
||||
if (plant.recoilDps) {
|
||||
zombie.takeDamage(plant.recoilDps * dt);
|
||||
if (zombie.health <= 0) {
|
||||
this.handleZombieDeath(zombie, gameState);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
zombie.targetPlant = touchingPlant;
|
||||
}
|
||||
|
||||
// 4. Breach Check
|
||||
for (const zombie of gameState.zombies) {
|
||||
if (zombie.toRemove || zombie.health <= 0) continue;
|
||||
if (zombie.x < 35) {
|
||||
if (gameState.currentMode === 'izombie') {
|
||||
// In I, Zombie, reaching the left end means zombie reached the house / brains!
|
||||
zombie.toRemove = true;
|
||||
if (window.PvZAudio) window.PvZAudio.playGulp();
|
||||
} else {
|
||||
gameState.triggerGameOver();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Projectiles vs Dr. Zomboss
|
||||
if (gameState.boss && gameState.boss.isActive) {
|
||||
for (const proj of gameState.projectiles) {
|
||||
if (!proj.toRemove && proj.x >= gameState.boss.x) {
|
||||
gameState.boss.takeDamage(proj.damage);
|
||||
this.createSplash(proj.x, proj.y, '#ffd54f', 8);
|
||||
proj.toRemove = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleZombieDeath(zombie, gameState) {
|
||||
const pts = zombie.type === 'buckethead' || zombie.type === 'football' ? 300 : (zombie.type === 'conehead' ? 200 : 100);
|
||||
gameState.addScore(pts);
|
||||
gameState.zombiesKilled++;
|
||||
|
||||
// Achievement: zombie_slayer
|
||||
if (gameState.achievements) {
|
||||
gameState.achievements.trigger('zombie_slayer');
|
||||
}
|
||||
|
||||
// Tree of Wisdom Cheat: Daisies
|
||||
if (gameState.tree && gameState.tree.cheats && gameState.tree.cheats.daisies) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
this.particles.push({
|
||||
x: zombie.x + (Math.random() - 0.5) * 30,
|
||||
y: zombie.y + 10 + (Math.random() - 0.5) * 16,
|
||||
vx: 0,
|
||||
vy: -8,
|
||||
color: '#ffffff',
|
||||
size: 4,
|
||||
life: 2.5,
|
||||
maxLife: 2.5
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Roguelike: relic_vampire_sap (heal most wounded plant)
|
||||
if (gameState.roguelike && gameState.currentMode === 'roguelike' && gameState.roguelike.hasRelic('relic_vampire_sap')) {
|
||||
let mostDamaged = null;
|
||||
let lowestHpRatio = 1.0;
|
||||
for (const p of gameState.plants) {
|
||||
if (p.toRemove || p.health <= 0) continue;
|
||||
const ratio = p.health / p.maxHealth;
|
||||
if (ratio < lowestHpRatio) {
|
||||
lowestHpRatio = ratio;
|
||||
mostDamaged = p;
|
||||
}
|
||||
}
|
||||
if (mostDamaged) {
|
||||
mostDamaged.health = Math.min(mostDamaged.maxHealth, mostDamaged.health + 40);
|
||||
}
|
||||
}
|
||||
|
||||
const perkBonus = gameState.workshop ? gameState.workshop.getPerkLevel('luckyDrop') * 0.35 : 0;
|
||||
|
||||
// Coins Drop
|
||||
if (Math.random() < 0.35 * (1 + perkBonus)) {
|
||||
const r = Math.random();
|
||||
const coinType = r < 0.15 ? 'diamond' : (r < 0.5 ? 'gold' : 'silver');
|
||||
gameState.spawnCoin(zombie.x, zombie.y, coinType);
|
||||
}
|
||||
|
||||
// Materials Drop (Workshop Crafting)
|
||||
if (gameState.workshop) {
|
||||
// 1. Scrap metal from armored enemies
|
||||
if (['buckethead', 'conehead', 'football', 'duckytube'].includes(zombie.type)) {
|
||||
if (Math.random() < 0.45 * (1 + perkBonus)) {
|
||||
gameState.workshop.spawnLoot(zombie.x, zombie.y, 'scrapMetal');
|
||||
}
|
||||
} else {
|
||||
// 2. Bio-humus from regular/organic enemies
|
||||
if (Math.random() < 0.35 * (1 + perkBonus)) {
|
||||
gameState.workshop.spawnLoot(zombie.x, zombie.y, 'bioHumus');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Rare Crystals from elite/flag enemies
|
||||
if (zombie.type === 'flag' || zombie.type === 'football' || Math.random() < 0.08 * (1 + perkBonus)) {
|
||||
if (Math.random() < 0.12 * (1 + perkBonus)) {
|
||||
gameState.workshop.spawnLoot(zombie.x, zombie.y, 'crystals');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
for (const p of this.particles) {
|
||||
ctx.save();
|
||||
const alpha = Math.max(0, p.life / p.maxLife);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,741 @@
|
||||
// Economy, Sun, Coin Drops & Crazy Dave's Shop System
|
||||
window.EconomySystem = class EconomySystem {
|
||||
constructor() {
|
||||
this.sun = 50;
|
||||
this.suns = [];
|
||||
this.coins = [];
|
||||
this.skySunTimer = 0;
|
||||
this.skySunInterval = 8.5;
|
||||
this.isNight = false;
|
||||
this.isConveyor = false; // For Wall-nut bowling mini-game
|
||||
this.isIZombie = false;
|
||||
|
||||
// Total coins & upgrades persistent storage
|
||||
this.reloadProfileData();
|
||||
|
||||
// Full Card Registry
|
||||
this.allCards = [
|
||||
{ id: 'sunflower', name: 'Подсолнух', cost: 50, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'peashooter', name: 'Горохострел', cost: 100, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'potatomine', name: 'Мина', cost: 25, cooldown: 18.0, timer: 0 },
|
||||
{ id: 'wallnut', name: 'Стеноорех', cost: 50, cooldown: 20.0, timer: 0 },
|
||||
{ id: 'repeater', name: 'Повторитель', cost: 200, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'snowpea', name: 'Ледяной', cost: 175, cooldown: 10.0, timer: 0 },
|
||||
{ id: 'chomper', name: 'Зубастик', cost: 150, cooldown: 10.0, timer: 0 },
|
||||
{ id: 'cherrybomb', name: 'Вишня', cost: 150, cooldown: 25.0, timer: 0 },
|
||||
{ id: 'jalapeno', name: 'Халапеньо', cost: 125, cooldown: 30.0, timer: 0 },
|
||||
{ id: 'sunshroom', name: 'Солнцегриб', cost: 25, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'fumeshroom', name: 'Дымогриб', cost: 75, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'lilypad', name: 'Кувшинка', cost: 25, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'gravebuster', name: 'Могилоед', cost: 75, cooldown: 15.0, timer: 0 },
|
||||
{ id: 'flowerpot', name: 'Горшок', cost: 25, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'cabbagepult', name: 'Капуста', cost: 100, cooldown: 7.5, timer: 0 },
|
||||
{ id: 'melonpult', name: 'Арбуз', cost: 300, cooldown: 15.0, timer: 0 }
|
||||
];
|
||||
|
||||
this.selectedCard = null;
|
||||
this.animTime = 0;
|
||||
this.setDeckForLevel(1, 'day');
|
||||
}
|
||||
|
||||
reloadProfileData() {
|
||||
if (window.PvZAuth) {
|
||||
const uData = window.PvZAuth.getUserData();
|
||||
this.totalCoins = uData.coins !== undefined ? uData.coins : 250;
|
||||
this.upgrades = uData.upgrades || { extraSlot: false, hasRake: false };
|
||||
} else {
|
||||
this.totalCoins = parseInt(localStorage.getItem('pvz_coins') || '250', 10);
|
||||
this.upgrades = {
|
||||
extraSlot: localStorage.getItem('pvz_upg_slot') === 'true',
|
||||
hasRake: localStorage.getItem('pvz_upg_rake') === 'true'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
saveProfileData() {
|
||||
localStorage.setItem('pvz_coins', this.totalCoins.toString());
|
||||
localStorage.setItem('pvz_upg_slot', this.upgrades.extraSlot ? 'true' : 'false');
|
||||
localStorage.setItem('pvz_upg_rake', this.upgrades.hasRake ? 'true' : 'false');
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
window.PvZAuth.saveUserData({
|
||||
coins: this.totalCoins,
|
||||
upgrades: this.upgrades
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDeckForLevel(level = 1, biome = 'day') {
|
||||
this.isNight = biome === 'night';
|
||||
this.isConveyor = false;
|
||||
this.isIZombie = false;
|
||||
|
||||
// Card slots count (5 default, 6 with Dave's extra slot upgrade)
|
||||
const slotCount = this.upgrades.extraSlot ? 6 : 5;
|
||||
|
||||
// Choose relevant deck based on biome and progression
|
||||
let availableIds = [];
|
||||
if (biome === 'roof') {
|
||||
availableIds = ['flowerpot', 'cabbagepult', 'melonpult', 'wallnut', 'jalapeno', 'potatomine'];
|
||||
} else if (biome === 'fog') {
|
||||
availableIds = ['sunshroom', 'fumeshroom', 'lilypad', 'cabbagepult', 'wallnut', 'snowpea'];
|
||||
} else if (biome === 'night') {
|
||||
availableIds = ['sunshroom', 'fumeshroom', 'potatomine', 'wallnut', 'gravebuster', 'hypnoshroom'];
|
||||
} else if (biome === 'pool') {
|
||||
availableIds = ['sunflower', 'peashooter', 'lilypad', 'wallnut', 'repeater', 'snowpea'];
|
||||
} else {
|
||||
if (level === 1) availableIds = ['sunflower', 'peashooter', 'potatomine', 'wallnut', 'cherrybomb'];
|
||||
else if (level === 2) availableIds = ['sunflower', 'peashooter', 'repeater', 'wallnut', 'jalapeno'];
|
||||
else availableIds = ['sunflower', 'peashooter', 'snowpea', 'chomper', 'wallnut', 'jalapeno'];
|
||||
}
|
||||
|
||||
this.cards = this.allCards.filter(c => availableIds.includes(c.id)).slice(0, slotCount);
|
||||
}
|
||||
|
||||
setBossDeck() {
|
||||
this.isConveyor = true;
|
||||
this.isIZombie = false;
|
||||
this.cards = [
|
||||
{ id: 'flowerpot', name: 'Горшок', cost: 0, cooldown: 3.5, timer: 0 },
|
||||
{ id: 'cabbagepult', name: 'Капуста', cost: 0, cooldown: 5.0, timer: 0 },
|
||||
{ id: 'melonpult', name: 'Арбуз', cost: 0, cooldown: 10.0, timer: 0 },
|
||||
{ id: 'jalapeno', name: 'Халапеньо', cost: 0, cooldown: 14.0, timer: 0 },
|
||||
{ id: 'snowpea', name: 'Ледяной', cost: 0, cooldown: 8.0, timer: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
setConveyorDeck() {
|
||||
this.isConveyor = true;
|
||||
this.isIZombie = false;
|
||||
this.cards = [
|
||||
{ id: 'bowling_nut', name: 'Орех-шар', cost: 0, cooldown: 4.0, timer: 0 },
|
||||
{ id: 'bowling_cherry', name: 'Взрывной', cost: 0, cooldown: 12.0, timer: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
setIZombieDeck() {
|
||||
this.isConveyor = false;
|
||||
this.isIZombie = true;
|
||||
this.isNight = true;
|
||||
this.sun = 200;
|
||||
this.cards = [
|
||||
{ id: 'z_normal', name: 'Обычный', cost: 50, cooldown: 2.0, timer: 0 },
|
||||
{ id: 'z_conehead', name: 'Конус', cost: 75, cooldown: 3.0, timer: 0 },
|
||||
{ id: 'z_polevaulter', name: 'С шестом', cost: 75, cooldown: 4.0, timer: 0 },
|
||||
{ id: 'z_buckethead', name: 'Ведро', cost: 125, cooldown: 5.0, timer: 0 },
|
||||
{ id: 'z_football', name: 'Футболист', cost: 175, cooldown: 6.0, timer: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
reset() {
|
||||
let baseSun = this.isConveyor ? 0 : (this.isIZombie ? 200 : 50);
|
||||
if (!this.isConveyor && !this.isIZombie && this.workshop) {
|
||||
baseSun += this.workshop.getPerkLevel('startSun') * 50;
|
||||
}
|
||||
this.sun = baseSun;
|
||||
this.suns = [];
|
||||
this.coins = [];
|
||||
this.skySunTimer = 0;
|
||||
this.selectedCard = null;
|
||||
this.cards.forEach(c => c.timer = 0);
|
||||
}
|
||||
|
||||
addSun(amount) {
|
||||
this.sun += amount;
|
||||
if (this.engine && this.engine.achievements) {
|
||||
this.engine.achievements.setProgress('sun_tycoon', this.sun);
|
||||
}
|
||||
}
|
||||
|
||||
canAfford(cost) {
|
||||
return this.isConveyor || this.sun >= cost;
|
||||
}
|
||||
|
||||
spendSun(cost) {
|
||||
if (this.isConveyor) return true;
|
||||
if (this.sun >= cost) {
|
||||
this.sun -= cost;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
addCoins(amount) {
|
||||
this.totalCoins += amount;
|
||||
this.saveProfileData();
|
||||
}
|
||||
|
||||
buyUpgrade(type) {
|
||||
if (type === 'extraSlot' && this.totalCoins >= 500 && !this.upgrades.extraSlot) {
|
||||
this.addCoins(-500);
|
||||
this.upgrades.extraSlot = true;
|
||||
this.saveProfileData();
|
||||
this.setDeckForLevel(1, this.isNight ? 'night' : 'day');
|
||||
return true;
|
||||
} else if (type === 'rake' && this.totalCoins >= 150 && !this.upgrades.hasRake) {
|
||||
this.addCoins(-150);
|
||||
this.upgrades.hasRake = true;
|
||||
this.saveProfileData();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
spawnSun(x, y, value = 25, isFalling = false, targetY = null) {
|
||||
this.suns.push({
|
||||
x,
|
||||
y,
|
||||
targetY: targetY !== null ? targetY : y,
|
||||
value,
|
||||
isFalling,
|
||||
fallSpeed: 90,
|
||||
radius: 26,
|
||||
life: 13.0,
|
||||
collecting: false,
|
||||
toRemove: false,
|
||||
pulse: Math.random() * 5
|
||||
});
|
||||
}
|
||||
|
||||
spawnCoin(x, y, type = 'silver') {
|
||||
const val = type === 'diamond' ? 200 : (type === 'gold' ? 50 : 10);
|
||||
this.coins.push({
|
||||
x,
|
||||
y,
|
||||
type,
|
||||
value: val,
|
||||
radius: 16,
|
||||
life: 12.0,
|
||||
collecting: false,
|
||||
toRemove: false,
|
||||
pulse: Math.random() * 5
|
||||
});
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
this.animTime += dt;
|
||||
|
||||
// Cooldown timers with fastCool perk bonus
|
||||
const fastCoolLvl = this.workshop ? this.workshop.getPerkLevel('fastCool') : 0;
|
||||
const cooldownMultiplier = 1 + fastCoolLvl * 0.15;
|
||||
for (const card of this.cards) {
|
||||
if (card.timer > 0) {
|
||||
card.timer = Math.max(0, card.timer - dt * cooldownMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
// Sky sun spawner (disabled at night and in conveyor mode)
|
||||
if (!this.isNight && !this.isConveyor) {
|
||||
this.skySunTimer += dt;
|
||||
if (this.skySunTimer >= this.skySunInterval) {
|
||||
this.skySunTimer = 0;
|
||||
const spawnX = 140 + Math.random() * 640;
|
||||
const targetY = 140 + Math.random() * 380;
|
||||
this.spawnSun(spawnX, 40, 25, true, targetY);
|
||||
}
|
||||
}
|
||||
|
||||
// Active suns
|
||||
for (const s of this.suns) {
|
||||
s.pulse += dt * 4;
|
||||
if (s.collecting) {
|
||||
const targetX = 50;
|
||||
const targetY = 45;
|
||||
const dx = targetX - s.x;
|
||||
const dy = targetY - s.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
if (dist < 15) {
|
||||
this.addSun(s.value);
|
||||
s.toRemove = true;
|
||||
} else {
|
||||
s.x += (dx / dist) * 750 * dt;
|
||||
s.y += (dy / dist) * 750 * dt;
|
||||
}
|
||||
} else {
|
||||
if (s.isFalling && s.y < s.targetY) {
|
||||
s.y += s.fallSpeed * dt;
|
||||
}
|
||||
s.life -= dt;
|
||||
if (s.life <= 0) s.toRemove = true;
|
||||
}
|
||||
}
|
||||
this.suns = this.suns.filter(s => !s.toRemove);
|
||||
|
||||
// Active coins
|
||||
for (const c of this.coins) {
|
||||
c.pulse += dt * 5;
|
||||
if (c.collecting) {
|
||||
const targetX = 850;
|
||||
const targetY = 45;
|
||||
const dx = targetX - c.x;
|
||||
const dy = targetY - c.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
if (dist < 15) {
|
||||
this.addCoins(c.value);
|
||||
c.toRemove = true;
|
||||
} else {
|
||||
c.x += (dx / dist) * 750 * dt;
|
||||
c.y += (dy / dist) * 750 * dt;
|
||||
}
|
||||
} else {
|
||||
c.life -= dt;
|
||||
if (c.life <= 0) c.toRemove = true;
|
||||
}
|
||||
}
|
||||
this.coins = this.coins.filter(c => !c.toRemove);
|
||||
}
|
||||
|
||||
handleClick(mouseX, mouseY) {
|
||||
// 0. Workshop Floating Loot
|
||||
if (this.workshop) {
|
||||
for (let i = this.workshop.floatingLoot.length - 1; i >= 0; i--) {
|
||||
const loot = this.workshop.floatingLoot[i];
|
||||
if (!loot.collected && Math.hypot(mouseX - loot.x, mouseY - loot.y) <= 30) {
|
||||
this.workshop.collectLoot(loot);
|
||||
return { handled: true, action: 'loot_collected' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Suns
|
||||
for (let i = this.suns.length - 1; i >= 0; i--) {
|
||||
const s = this.suns[i];
|
||||
if (!s.collecting && Math.hypot(mouseX - s.x, mouseY - s.y) <= s.radius + 14) {
|
||||
s.collecting = true;
|
||||
if (window.PvZAudio) window.PvZAudio.playSun();
|
||||
if (this.workshop) {
|
||||
this.sunCount = (this.sunCount || 0) + 1;
|
||||
if (this.sunCount >= 8) {
|
||||
this.sunCount = 0;
|
||||
this.workshop.addMaterial('sunEssence', 1);
|
||||
}
|
||||
}
|
||||
return { handled: true, action: 'sun_collected' };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Coins
|
||||
for (let i = this.coins.length - 1; i >= 0; i--) {
|
||||
const c = this.coins[i];
|
||||
if (!c.collecting && Math.hypot(mouseX - c.x, mouseY - c.y) <= c.radius + 14) {
|
||||
c.collecting = true;
|
||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||
return { handled: true, action: 'coin_collected' };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Seed Cards
|
||||
const cardStartX = 105;
|
||||
const cardY = 8;
|
||||
const cardW = 66;
|
||||
const cardH = 72;
|
||||
const spacing = 72;
|
||||
|
||||
for (let i = 0; i < this.cards.length; i++) {
|
||||
const card = this.cards[i];
|
||||
const cx = cardStartX + i * spacing;
|
||||
if (mouseX >= cx && mouseX <= cx + cardW && mouseY >= cardY && mouseY <= cardY + cardH) {
|
||||
if (this.canAfford(card.cost) && card.timer <= 0) {
|
||||
this.selectedCard = (this.selectedCard === card.id) ? null : card.id;
|
||||
}
|
||||
return { handled: true, action: 'card_selected' };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Shovel
|
||||
const shovelX = cardStartX + this.cards.length * spacing + 12;
|
||||
if (mouseX >= shovelX && mouseX <= shovelX + 54 && mouseY >= cardY && mouseY <= cardY + cardH) {
|
||||
this.selectedCard = (this.selectedCard === 'shovel') ? null : 'shovel';
|
||||
return { handled: true, action: 'shovel_selected' };
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
startCooldown(cardId) {
|
||||
const card = this.cards.find(c => c.id === cardId);
|
||||
if (card) {
|
||||
card.timer = card.cooldown;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
// HUD Bar Background
|
||||
ctx.fillStyle = this.isNight ? '#1a100c' : '#2c1810';
|
||||
ctx.fillRect(0, 0, 900, 90);
|
||||
ctx.fillStyle = this.isNight ? '#2a1810' : '#422817';
|
||||
ctx.fillRect(0, 0, 900, 86);
|
||||
|
||||
// 1. Sun Bank Box (or Conveyor indicator)
|
||||
if (!this.isConveyor) {
|
||||
ctx.fillStyle = '#e0c080';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(10, 8, 85, 72, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#6d4c41';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
this.drawSunGraphic(ctx, 36, 34, 16, 0, 1);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.sun, 52, 66);
|
||||
} else {
|
||||
// Conveyor belt indicator
|
||||
ctx.fillStyle = '#616161';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(10, 8, 85, 72, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('КОНВЕЙЕР', 52, 48);
|
||||
}
|
||||
|
||||
// 2. Draw Cards
|
||||
const cardStartX = 105;
|
||||
const cardY = 8;
|
||||
const cardW = 66;
|
||||
const cardH = 72;
|
||||
const spacing = 72;
|
||||
|
||||
for (let i = 0; i < this.cards.length; i++) {
|
||||
const card = this.cards[i];
|
||||
const cx = cardStartX + i * spacing;
|
||||
const isSelected = this.selectedCard === card.id;
|
||||
const affordable = this.canAfford(card.cost);
|
||||
const ready = card.timer <= 0;
|
||||
|
||||
ctx.fillStyle = isSelected ? '#fff9c4' : (affordable && ready ? '#fff8e1' : '#b0bec5');
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(cx, cardY, cardW, cardH, 6);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = isSelected ? '#ffeb3b' : (affordable && ready ? '#8d6e63' : '#546e7a');
|
||||
ctx.lineWidth = isSelected ? 3 : 2;
|
||||
ctx.stroke();
|
||||
|
||||
this.drawCardThumbnail(ctx, card.id, cx + cardW / 2, cardY + 28);
|
||||
|
||||
if (this.workshop) {
|
||||
const lvl = this.workshop.getPlantLevel(card.id);
|
||||
if (lvl > 1) {
|
||||
ctx.fillStyle = lvl === 3 ? '#e65100' : '#f57f17';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(lvl === 3 ? '★★★' : '★★', cx + cardW - 4, cardY + 11);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#263238';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(card.name, cx + cardW / 2, cardY + 54);
|
||||
|
||||
if (!this.isConveyor) {
|
||||
ctx.fillStyle = affordable ? '#1b5e20' : '#b71c1c';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.fillText(card.cost, cx + cardW / 2, cardY + 68);
|
||||
} else {
|
||||
ctx.fillStyle = '#1b5e20';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.fillText('БЕСПЛАТНО', cx + cardW / 2, cardY + 68);
|
||||
}
|
||||
|
||||
if (card.timer > 0) {
|
||||
const cdRatio = card.timer / card.cooldown;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.beginPath();
|
||||
ctx.rect(cx, cardY, cardW, cardH * cdRatio);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 13px sans-serif';
|
||||
ctx.fillText(Math.ceil(card.timer), cx + cardW / 2, cardY + cardH / 2 + 5);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shovel
|
||||
const shovelX = cardStartX + this.cards.length * spacing + 12;
|
||||
const isShovelSelected = this.selectedCard === 'shovel';
|
||||
ctx.fillStyle = isShovelSelected ? '#ffcdd2' : '#efebe9';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(shovelX, cardY, 54, cardH, 6);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isShovelSelected ? '#d32f2f' : '#8d6e63';
|
||||
ctx.lineWidth = isShovelSelected ? 3 : 2;
|
||||
ctx.stroke();
|
||||
|
||||
this.drawShovelGraphic(ctx, shovelX + 27, cardY + 36);
|
||||
ctx.fillStyle = '#37474f';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Лопата', shovelX + 27, cardY + 66);
|
||||
|
||||
// 4. Coin Bag / Wallet in Top Right
|
||||
ctx.fillStyle = '#ffe082';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(595, 20, 80, 26, 4);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ff8f00';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Coin icon
|
||||
ctx.fillStyle = '#ffd700';
|
||||
ctx.beginPath();
|
||||
ctx.arc(608, 33, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#3e2723';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`💰 ${this.totalCoins}`, 645, 38);
|
||||
|
||||
// 5. Active Suns
|
||||
for (const s of this.suns) {
|
||||
const alpha = Math.min(1, s.life / 1.5);
|
||||
this.drawSunGraphic(ctx, s.x, s.y, s.radius, s.pulse, alpha);
|
||||
}
|
||||
|
||||
// 6. Active Coins
|
||||
for (const c of this.coins) {
|
||||
this.drawCoinGraphic(ctx, c.x, c.y, c.radius, c.type, c.pulse);
|
||||
}
|
||||
}
|
||||
|
||||
drawSunGraphic(ctx, x, y, radius, pulseAngle, alpha = 1) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.translate(x, y);
|
||||
|
||||
const numRays = 12;
|
||||
ctx.fillStyle = '#fbc02d';
|
||||
for (let i = 0; i < numRays; i++) {
|
||||
ctx.save();
|
||||
ctx.rotate(pulseAngle + (i * Math.PI * 2) / numRays);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-radius * 0.25, -radius * 0.8);
|
||||
ctx.lineTo(0, -radius * 1.35);
|
||||
ctx.lineTo(radius * 0.25, -radius * 0.8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const grad = ctx.createRadialGradient(0, 0, radius * 0.3, 0, 0, radius);
|
||||
grad.addColorStop(0, '#fffde7');
|
||||
grad.addColorStop(0.5, '#fff176');
|
||||
grad.addColorStop(1, '#fbc02d');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawCoinGraphic(ctx, x, y, radius, type, pulse) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
const spin = Math.sin(pulse * 3);
|
||||
ctx.scale(spin, 1);
|
||||
|
||||
if (type === 'gold') {
|
||||
ctx.fillStyle = '#ffd700';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#b78103';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#b78103';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('$', 0, 4);
|
||||
} else if (type === 'diamond') {
|
||||
ctx.fillStyle = '#80deea';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -radius);
|
||||
ctx.lineTo(radius, 0);
|
||||
ctx.lineTo(0, radius);
|
||||
ctx.lineTo(-radius, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else {
|
||||
// Silver
|
||||
ctx.fillStyle = '#e0e0e0';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#757575';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawCardThumbnail(ctx, type, x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(0.52, 0.52);
|
||||
|
||||
if (type === 'sunflower') {
|
||||
ctx.fillStyle = '#fbc02d';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.save();
|
||||
ctx.rotate((i * Math.PI * 2) / 8);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -18, 6, 12, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.fillStyle = '#5d4037';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'peashooter' || type === 'snowpea' || type === 'repeater') {
|
||||
const col = type === 'snowpea' ? '#29b6f6' : (type === 'repeater' ? '#2e7d32' : '#4caf50');
|
||||
ctx.fillStyle = col;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(14, 0, 8, 10, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'wallnut' || type === 'bowling_nut') {
|
||||
ctx.fillStyle = '#8d6e63';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, 16, 22, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'cherrybomb' || type === 'bowling_cherry') {
|
||||
ctx.fillStyle = '#d32f2f';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-9, 4, 12, 0, Math.PI * 2);
|
||||
ctx.arc(9, 4, 12, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'potatomine') {
|
||||
ctx.fillStyle = '#a1887f';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 6, 16, 12, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#f44336';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -6, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'chomper') {
|
||||
ctx.fillStyle = '#7b1fa2';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 18, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'jalapeno') {
|
||||
ctx.fillStyle = '#d50000';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, 10, 20, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'sunshroom') {
|
||||
ctx.fillStyle = '#fbc02d';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 14, Math.PI, 0);
|
||||
ctx.fill();
|
||||
} else if (type === 'fumeshroom') {
|
||||
ctx.fillStyle = '#8e24aa';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 16, Math.PI, 0);
|
||||
ctx.fill();
|
||||
} else if (type === 'lilypad') {
|
||||
ctx.fillStyle = '#2e7d32';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, 20, 10, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'gravebuster') {
|
||||
ctx.strokeStyle = '#33691e';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 14, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (type === 'flowerpot') {
|
||||
ctx.fillStyle = '#bf360c';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-14, 4);
|
||||
ctx.lineTo(14, 4);
|
||||
ctx.lineTo(10, 16);
|
||||
ctx.lineTo(-10, 16);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#d84315';
|
||||
ctx.fillRect(-16, 0, 32, 5);
|
||||
} else if (type === 'cabbagepult') {
|
||||
ctx.fillStyle = '#8d6e63';
|
||||
ctx.fillRect(-12, 6, 24, 6);
|
||||
ctx.fillStyle = '#81c784';
|
||||
ctx.beginPath();
|
||||
ctx.arc(4, -4, 11, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type === 'melonpult') {
|
||||
ctx.fillStyle = '#5d4037';
|
||||
ctx.fillRect(-14, 6, 28, 8);
|
||||
ctx.fillStyle = '#2e7d32';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(4, -4, 14, 11, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (type && type.startsWith('z_')) {
|
||||
ctx.fillStyle = '#809973';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-12, -14, 24, 26, [8, 8, 4, 4]);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff9c4';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-5, -4, 4, 0, Math.PI * 2);
|
||||
ctx.arc(5, -4, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-5, -4, 1.5, 0, Math.PI * 2);
|
||||
ctx.arc(5, -4, 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (type === 'z_conehead') {
|
||||
ctx.fillStyle = '#ff6d00';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-10, -12); ctx.lineTo(10, -12); ctx.lineTo(0, -30); ctx.closePath();
|
||||
ctx.fill();
|
||||
} else if (type === 'z_buckethead') {
|
||||
ctx.fillStyle = '#90a4ae';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-12, -26, 24, 16, 4);
|
||||
ctx.fill();
|
||||
} else if (type === 'z_football') {
|
||||
ctx.fillStyle = '#b71c1c';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -10, 15, Math.PI, 0);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawShovelGraphic(ctx, x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(0.35);
|
||||
ctx.strokeStyle = '#8d6e63';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -16);
|
||||
ctx.lineTo(0, 8);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#90a4ae';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-9, 8);
|
||||
ctx.lineTo(9, 8);
|
||||
ctx.lineTo(6, 20);
|
||||
ctx.lineTo(0, 24);
|
||||
ctx.lineTo(-6, 20);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
// Bug Reporting & Community Feedback Chat System
|
||||
window.PvZFeedback = (function() {
|
||||
const STORAGE_KEY = 'pvz_cached_bugs';
|
||||
let cachedReports = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
function getLocalFallback() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch (e) {}
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
author: "Player_Kopri",
|
||||
category: "bug",
|
||||
text: "Зомби после поедания растения не шел дальше, а оставался стоять на месте.",
|
||||
status: "исправлено",
|
||||
timestamp: "2026-09-16 09:30",
|
||||
adminReply: "Исправлено в v1.6: сброшен флаг isEating и таймер поедания."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: "DaveFan",
|
||||
category: "bug",
|
||||
text: "Режим 'Я, Зомби' не работал: сразу после старта выводилось сообщение 'победа'.",
|
||||
status: "исправлено",
|
||||
timestamp: "2026-09-16 09:45",
|
||||
adminReply: "Исправлено в v1.6: устранено состояние гонки при инициализации газона."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: "ZombossHater",
|
||||
category: "visual",
|
||||
text: "Элементы главного меню и кнопки вываливаются за границы экрана на небольших дисплеях.",
|
||||
status: "исправлено",
|
||||
timestamp: "2026-09-16 10:50",
|
||||
adminReply: "Исправлено в v2.2: адаптивная панель навигации, компактные карточки и вертикальный скролл."
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
init: function() {
|
||||
cachedReports = getLocalFallback();
|
||||
this.fetchReports();
|
||||
},
|
||||
|
||||
getReports: function() {
|
||||
return cachedReports;
|
||||
},
|
||||
|
||||
fetchReports: async function(onDone) {
|
||||
try {
|
||||
const resp = await fetch('/api/bugs', { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data)) {
|
||||
cachedReports = data;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch online bugs, using local cache:', err);
|
||||
if (cachedReports.length === 0) {
|
||||
cachedReports = getLocalFallback();
|
||||
}
|
||||
}
|
||||
if (typeof onDone === 'function') onDone(cachedReports);
|
||||
this.renderFeed(currentFilter);
|
||||
},
|
||||
|
||||
sendReport: async function(author, category, text, onResult) {
|
||||
const payload = {
|
||||
author: (author || 'Гость').trim(),
|
||||
category: category || 'bug',
|
||||
text: (text || '').trim(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (!payload.text) {
|
||||
if (onResult) onResult({ success: false, message: 'Введите описание проблемы' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/bugs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const res = await resp.json();
|
||||
await this.fetchReports();
|
||||
if (onResult) onResult({ success: true, report: res.report });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to POST to server, storing locally:', err);
|
||||
}
|
||||
|
||||
// Offline fallback: save into local list
|
||||
const newId = (cachedReports.reduce((m, r) => Math.max(m, r.id || 0), 0) || 0) + 1;
|
||||
const localReport = {
|
||||
id: newId,
|
||||
author: payload.author,
|
||||
category: payload.category,
|
||||
text: payload.text,
|
||||
status: 'новый',
|
||||
timestamp: new Date().toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' }),
|
||||
adminReply: null
|
||||
};
|
||||
cachedReports.push(localReport);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedReports));
|
||||
} catch (e) {}
|
||||
this.renderFeed(currentFilter);
|
||||
if (onResult) onResult({ success: true, report: localReport, offline: true });
|
||||
},
|
||||
|
||||
setFilter: function(filter) {
|
||||
currentFilter = filter;
|
||||
this.renderFeed(filter);
|
||||
},
|
||||
|
||||
renderFeed: function(filter = currentFilter) {
|
||||
currentFilter = filter;
|
||||
const feedEl = document.getElementById('bugreport-feed');
|
||||
const countBadge = document.getElementById('bug-count-badge');
|
||||
if (countBadge) {
|
||||
const total = cachedReports.length;
|
||||
const word = total % 10 === 1 && total % 100 !== 11 ? 'репорт' : (total % 10 >= 2 && total % 10 <= 4 && (total % 100 < 10 || total % 100 >= 20) ? 'репорта' : 'репортов');
|
||||
countBadge.textContent = `${total} ${word}`;
|
||||
}
|
||||
if (!feedEl) return;
|
||||
|
||||
let list = [...cachedReports];
|
||||
if (filter === 'bug') list = list.filter(r => r.category === 'bug');
|
||||
else if (filter === 'feature') list = list.filter(r => r.category === 'feature');
|
||||
else if (filter === 'visual') list = list.filter(r => r.category === 'visual');
|
||||
else if (filter === 'balance') list = list.filter(r => r.category === 'balance');
|
||||
else if (filter === 'resolved') list = list.filter(r => r.status === 'исправлено');
|
||||
|
||||
if (list.length === 0) {
|
||||
feedEl.innerHTML = '<div class="bug-empty">Сообщений в этой категории пока нет. Напишите первое! 💬</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show newest first
|
||||
list.sort((a, b) => (b.id || 0) - (a.id || 0));
|
||||
|
||||
const categoryLabels = {
|
||||
bug: { text: '🔴 Баг', cls: 'cat-bug' },
|
||||
feature: { text: '💡 Идея', cls: 'cat-feature' },
|
||||
visual: { text: '🎨 Графика/UI', cls: 'cat-visual' },
|
||||
balance: { text: '⚖️ Баланс', cls: 'cat-balance' }
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
'новый': { text: '🔴 Новый', cls: 'status-new' },
|
||||
'в работе': { text: '🟡 В работе', cls: 'status-progress' },
|
||||
'исправлено': { text: '✅ Исправлено', cls: 'status-done' }
|
||||
};
|
||||
|
||||
let html = '';
|
||||
list.forEach(r => {
|
||||
const cat = categoryLabels[r.category] || categoryLabels.bug;
|
||||
const st = statusLabels[r.status] || statusLabels['новый'];
|
||||
const replyHtml = r.adminReply
|
||||
? `<div class="bug-reply">
|
||||
<span class="reply-tag">🛠️ Ответ разработчика:</span>
|
||||
<div class="reply-text">${this.escapeHtml(r.adminReply)}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
html += `
|
||||
<div class="bug-card ${r.status === 'исправлено' ? 'is-fixed' : ''}">
|
||||
<div class="bug-card-top">
|
||||
<div class="bug-author">👤 <strong>${this.escapeHtml(r.author || 'Гость')}</strong></div>
|
||||
<div class="bug-meta">
|
||||
<span class="bug-category ${cat.cls}">${cat.text}</span>
|
||||
<span class="bug-status ${st.cls}">${st.text}</span>
|
||||
<span class="bug-time">🕒 ${this.escapeHtml(r.timestamp || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bug-text">${this.escapeHtml(r.text)}</div>
|
||||
${replyHtml}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
feedEl.innerHTML = html;
|
||||
},
|
||||
|
||||
escapeHtml: function(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,428 @@
|
||||
// Grid System (Day, Night with Graves, and Pool with Water Lanes)
|
||||
window.GridSystem = class GridSystem {
|
||||
constructor() {
|
||||
this.biome = 'day'; // 'day', 'night', 'pool'
|
||||
this.cols = 9;
|
||||
this.startX = 120;
|
||||
this.startY = 90;
|
||||
this.cellW = 80;
|
||||
|
||||
this.graves = [];
|
||||
this.animTime = 0;
|
||||
this.initBiome('day');
|
||||
}
|
||||
|
||||
initBiome(biome = 'day') {
|
||||
this.biome = biome;
|
||||
this.rows = (biome === 'pool' || biome === 'fog') ? 6 : 5;
|
||||
this.cellH = (biome === 'pool' || biome === 'fog') ? 83 : 100;
|
||||
|
||||
this.grid = [];
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
this.grid[r] = [];
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
this.grid[r][c] = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.graves = [];
|
||||
if (biome === 'night') {
|
||||
this.graves = [
|
||||
{ col: 7, row: 1 },
|
||||
{ col: 8, row: 3 },
|
||||
{ col: 6, row: 4 }
|
||||
];
|
||||
}
|
||||
|
||||
this.initLawnmowers();
|
||||
}
|
||||
|
||||
initLawnmowers() {
|
||||
this.lawnmowers = [];
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
const isWater = (this.biome === 'pool' || this.biome === 'fog') && (r === 2 || r === 3);
|
||||
this.lawnmowers.push({
|
||||
row: r,
|
||||
x: 65,
|
||||
y: this.startY + r * this.cellH + this.cellH / 2,
|
||||
active: false,
|
||||
used: false,
|
||||
speed: 650,
|
||||
isCleaner: isWater
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.initBiome(this.biome);
|
||||
}
|
||||
|
||||
isWaterCell(row) {
|
||||
return (this.biome === 'pool' || this.biome === 'fog') && (row === 2 || row === 3);
|
||||
}
|
||||
|
||||
isRoof() {
|
||||
return this.biome === 'roof';
|
||||
}
|
||||
|
||||
isFog() {
|
||||
return this.biome === 'fog';
|
||||
}
|
||||
|
||||
hasGrave(col, row) {
|
||||
return this.graves.some(g => g.col === col && g.row === row);
|
||||
}
|
||||
|
||||
removeGrave(col, row) {
|
||||
this.graves = this.graves.filter(g => !(g.col === col && g.row === row));
|
||||
}
|
||||
|
||||
getCellFromCoords(x, y) {
|
||||
if (x < this.startX || x >= this.startX + this.cols * this.cellW) return null;
|
||||
if (y < this.startY || y >= this.startY + this.rows * this.cellH) return null;
|
||||
|
||||
const col = Math.floor((x - this.startX) / this.cellW);
|
||||
const row = Math.floor((y - this.startY) / this.cellH);
|
||||
|
||||
if (col >= 0 && col < this.cols && row >= 0 && row < this.rows) {
|
||||
return { col, row };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getCellBounds(col, row) {
|
||||
return {
|
||||
x: this.startX + col * this.cellW,
|
||||
y: this.startY + row * this.cellH,
|
||||
w: this.cellW,
|
||||
h: this.cellH,
|
||||
centerX: this.startX + col * this.cellW + this.cellW / 2,
|
||||
centerY: this.startY + row * this.cellH + this.cellH / 2
|
||||
};
|
||||
}
|
||||
|
||||
getPlant(col, row) {
|
||||
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
||||
return this.grid[row][col];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setPlant(col, row, plant) {
|
||||
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
||||
this.grid[row][col] = plant;
|
||||
}
|
||||
}
|
||||
|
||||
removePlant(col, row) {
|
||||
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
||||
this.grid[row][col] = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateLawnmowers(dt, zombies) {
|
||||
this.animTime += dt;
|
||||
for (const mower of this.lawnmowers) {
|
||||
if (mower.used) continue;
|
||||
|
||||
if (mower.active) {
|
||||
mower.x += mower.speed * dt;
|
||||
for (const z of zombies) {
|
||||
if (z.row === mower.row && Math.abs(z.x - mower.x) < 45 && z.health > 0) {
|
||||
z.takeDamage(9999);
|
||||
}
|
||||
}
|
||||
if (mower.x > 960) {
|
||||
mower.used = true;
|
||||
mower.active = false;
|
||||
}
|
||||
} else {
|
||||
for (const z of zombies) {
|
||||
if (z.row === mower.row && z.x <= 115 && z.health > 0) {
|
||||
mower.active = true;
|
||||
if (window.PvZAudio) window.PvZAudio.playMower();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, hoverCell, selectedPlantType) {
|
||||
// 1. Draw Lawn Tiles or Pool
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
const isWater = this.isWaterCell(r);
|
||||
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const x = this.startX + c * this.cellW;
|
||||
const y = this.startY + r * this.cellH;
|
||||
const isEven = (r + c) % 2 === 0;
|
||||
|
||||
if (this.biome === 'roof') {
|
||||
// Terracotta Roof Shingles
|
||||
ctx.fillStyle = isEven ? '#bf360c' : '#d84315';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
|
||||
// Slanted shingle pattern lines
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + this.cellH * 0.5);
|
||||
ctx.lineTo(x + this.cellW, y + this.cellH * 0.5);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 10, y);
|
||||
ctx.lineTo(x + 20, y + this.cellH);
|
||||
ctx.moveTo(x + 50, y);
|
||||
ctx.lineTo(x + 60, y + this.cellH);
|
||||
ctx.stroke();
|
||||
} else if (isWater) {
|
||||
// Pool Water
|
||||
ctx.fillStyle = isEven ? '#0288d1' : '#039be5';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
|
||||
// Subtle animated water ripples
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
const waveOff = Math.sin(this.animTime * 3 + c * 0.8 + r) * 3;
|
||||
ctx.moveTo(x + 10, y + 20 + waveOff);
|
||||
ctx.quadraticCurveTo(x + 40, y + 15 + waveOff, x + 70, y + 20 + waveOff);
|
||||
ctx.stroke();
|
||||
} else if (this.theme === 'cyberpunk') {
|
||||
// Cyberpunk Grid Tiles
|
||||
ctx.fillStyle = isEven ? '#120326' : '#1d063d';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.25)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
||||
ctx.fillStyle = 'rgba(233, 30, 99, 0.4)';
|
||||
ctx.fillRect(x + this.cellW - 3, y + this.cellH - 3, 3, 3);
|
||||
} else if (this.theme === 'winter') {
|
||||
// Winter Frost Lawn
|
||||
ctx.fillStyle = isEven ? '#e0f7fa' : '#b2ebf2';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
||||
} else if (this.theme === 'retro8bit') {
|
||||
// Retro 8-bit Pixel Lawn
|
||||
ctx.fillStyle = isEven ? '#15803d' : '#166534';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
ctx.strokeStyle = '#10b981';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
||||
} else if (this.biome === 'night' || this.biome === 'fog') {
|
||||
// Dark Night Lawn
|
||||
ctx.fillStyle = isEven ? '#1b5e20' : '#144618';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
} else {
|
||||
// Day Lawn
|
||||
ctx.fillStyle = isEven ? '#4caf50' : '#43a047';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Left Porch Deck / Roof Chimney
|
||||
if (this.biome === 'roof') {
|
||||
this.drawRoofBackground(ctx);
|
||||
} else {
|
||||
ctx.fillStyle = (this.biome === 'night' || this.biome === 'fog') ? '#4e342e' : '#795548';
|
||||
ctx.fillRect(0, this.startY, this.startX, this.rows * this.cellH);
|
||||
ctx.fillStyle = '#3e2723';
|
||||
ctx.fillRect(this.startX - 6, this.startY, 6, this.rows * this.cellH);
|
||||
|
||||
// 3. Right Grass Edge
|
||||
const rightEdge = this.startX + this.cols * this.cellW;
|
||||
ctx.fillStyle = (this.biome === 'night' || this.biome === 'fog') ? '#0d280f' : '#388e3c';
|
||||
ctx.fillRect(rightEdge, this.startY, 900 - rightEdge, this.rows * this.cellH);
|
||||
}
|
||||
|
||||
// 4. Draw Gravestones in Night Mode
|
||||
for (const grave of this.graves) {
|
||||
const bounds = this.getCellBounds(grave.col, grave.row);
|
||||
this.drawGravestone(ctx, bounds.centerX, bounds.centerY);
|
||||
}
|
||||
|
||||
// 5. Hover effect
|
||||
if (hoverCell && selectedPlantType) {
|
||||
const bounds = this.getCellBounds(hoverCell.col, hoverCell.row);
|
||||
const isWater = this.isWaterCell(hoverCell.row);
|
||||
const plant = this.getPlant(hoverCell.col, hoverCell.row);
|
||||
const isGrave = this.hasGrave(hoverCell.col, hoverCell.row);
|
||||
|
||||
let canPlace = false;
|
||||
if (selectedPlantType === 'shovel') {
|
||||
canPlace = !!plant;
|
||||
} else if (this.isRoof()) {
|
||||
if (selectedPlantType === 'flowerpot') {
|
||||
canPlace = !plant;
|
||||
} else {
|
||||
canPlace = plant && plant.type === 'flowerpot';
|
||||
}
|
||||
} else if (selectedPlantType === 'gravebuster') {
|
||||
canPlace = isGrave && !plant;
|
||||
} else if (isGrave) {
|
||||
canPlace = false;
|
||||
} else if (isWater) {
|
||||
canPlace = selectedPlantType === 'lilypad' || (plant && plant.type === 'lilypad');
|
||||
} else {
|
||||
canPlace = !plant;
|
||||
}
|
||||
|
||||
ctx.fillStyle = canPlace ? 'rgba(255, 255, 255, 0.35)' : 'rgba(244, 67, 54, 0.35)';
|
||||
ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
ctx.strokeStyle = canPlace ? '#ffffff' : '#f44336';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
}
|
||||
|
||||
// 6. Draw Lawnmowers & Pool Cleaners
|
||||
for (const mower of this.lawnmowers) {
|
||||
if (mower.used) continue;
|
||||
if (mower.isCleaner) {
|
||||
this.drawPoolCleaner(ctx, mower.x, mower.y);
|
||||
} else {
|
||||
this.drawLawnmower(ctx, mower.x, mower.y);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Volumetric Fog Layer
|
||||
if (this.biome === 'fog') {
|
||||
this.drawFogOverlay(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
drawRoofBackground(ctx) {
|
||||
// Porch and terracotta roof chimney
|
||||
ctx.fillStyle = '#4e342e';
|
||||
ctx.fillRect(0, this.startY, this.startX, this.rows * this.cellH);
|
||||
ctx.fillStyle = '#8d6e63';
|
||||
ctx.fillRect(25, this.startY + 40, 65, 120);
|
||||
ctx.strokeStyle = '#3e2723';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(25, this.startY + 40, 65, 120);
|
||||
|
||||
// Right roof edge
|
||||
const rightEdge = this.startX + this.cols * this.cellW;
|
||||
ctx.fillStyle = '#a02700';
|
||||
ctx.fillRect(rightEdge, this.startY, 900 - rightEdge, this.rows * this.cellH);
|
||||
}
|
||||
|
||||
drawFogOverlay(ctx) {
|
||||
ctx.save();
|
||||
for (let c = 4; c < this.cols; c++) {
|
||||
const fx = this.startX + c * this.cellW;
|
||||
const drift = Math.sin(this.animTime * 1.2 + c) * 8;
|
||||
ctx.fillStyle = 'rgba(38, 50, 56, 0.78)';
|
||||
ctx.fillRect(fx + drift, this.startY, this.cellW + 12, this.rows * this.cellH);
|
||||
}
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const cx = 500 + i * 55 + Math.sin(this.animTime * 0.9 + i) * 18;
|
||||
const cy = this.startY + (i * 70) % (this.rows * this.cellH);
|
||||
ctx.fillStyle = 'rgba(69, 90, 100, 0.42)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 46, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawGravestone(ctx, x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y + 10);
|
||||
|
||||
// Stone base shadow
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 24, 26, 8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Tombstone body
|
||||
ctx.fillStyle = '#757575';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-22, -26, 44, 48, [16, 16, 2, 2]);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#424242';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// RIP text
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('R.I.P.', 0, -2);
|
||||
|
||||
// Crack
|
||||
ctx.strokeStyle = '#212121';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-10, 8);
|
||||
ctx.lineTo(-4, 14);
|
||||
ctx.lineTo(-8, 20);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawLawnmower(ctx, x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-14, 16, 7, 0, Math.PI * 2);
|
||||
ctx.arc(14, 16, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#9e9e9e';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-14, 16, 3, 0, Math.PI * 2);
|
||||
ctx.arc(14, 16, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#d32f2f';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-22, -6, 44, 20, [6, 6, 2, 2]);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#424242';
|
||||
ctx.fillRect(-10, -18, 20, 14);
|
||||
|
||||
ctx.strokeStyle = '#bdbdbd';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-18, 0);
|
||||
ctx.lineTo(-32, -26);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawPoolCleaner(ctx, x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
// Blue floating cleaner
|
||||
ctx.fillStyle = '#0288d1';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-20, -10, 40, 24, 8);
|
||||
ctx.fill();
|
||||
|
||||
// Yellow propeller
|
||||
ctx.fillStyle = '#ffeb3b';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 2, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
// Leaderboard & High Score Registry System
|
||||
window.LeaderboardSystem = class LeaderboardSystem {
|
||||
constructor() {
|
||||
this.selectedMode = 'survival'; // default tab
|
||||
|
||||
// High score records per mode
|
||||
this.records = {
|
||||
adventure: [
|
||||
{ name: 'Безумный Дэйв', score: 10, detail: 'Уровень 10 (Пройдено)', date: '2026-09-01' },
|
||||
{ name: 'Сосед Боб', score: 8, detail: 'Уровень 8', date: '2026-09-10' },
|
||||
{ name: 'Пенни', score: 5, detail: 'Уровень 5', date: '2026-09-12' }
|
||||
],
|
||||
survival: [
|
||||
{ name: 'Безумный Дэйв', score: 32, detail: '32 Флага', date: '2026-09-02' },
|
||||
{ name: 'Зомби Йети', score: 18, detail: '18 Флагов', date: '2026-09-05' },
|
||||
{ name: 'Пенни', score: 12, detail: '12 Флагов', date: '2026-09-11' }
|
||||
],
|
||||
roguelike: [
|
||||
{ name: 'Безумный Дэйв', score: 14, detail: '14 Этажей (6 Реликвий)', date: '2026-09-03' },
|
||||
{ name: 'Сосед Боб', score: 9, detail: '9 Этажей', date: '2026-09-08' },
|
||||
{ name: 'Пенни', score: 6, detail: '6 Этажей', date: '2026-09-14' }
|
||||
],
|
||||
izombie: [
|
||||
{ name: 'Доктор Зомбосс', score: 50, detail: '50 Мозгов съедено', date: '2026-09-04' },
|
||||
{ name: 'Футболист Зомби', score: 25, detail: '25 Мозгов', date: '2026-09-09' },
|
||||
{ name: 'Обычный Зомби', score: 15, detail: '15 Мозгов', date: '2026-09-13' }
|
||||
],
|
||||
bowling: [
|
||||
{ name: 'Безумный Дэйв', score: 6800, detail: '6800 очков (Страйк x8)', date: '2026-09-06' },
|
||||
{ name: 'Пенни', score: 4500, detail: '4500 очков', date: '2026-09-07' },
|
||||
{ name: 'Сосед Боб', score: 3200, detail: '3200 очков', date: '2026-09-15' }
|
||||
],
|
||||
boss: [
|
||||
{ name: 'Безумный Дэйв', score: 1, detail: 'Побежден за 2 мин 15 сек', date: '2026-09-15' }
|
||||
],
|
||||
bossrush: [
|
||||
{ name: 'Безумный Дэйв', score: 3, detail: 'Boss Rush завершен! (3 волны)', date: '2026-09-16' },
|
||||
{ name: 'Сосед Боб', score: 2, detail: '2 волны', date: '2026-09-16' }
|
||||
]
|
||||
};
|
||||
|
||||
this.loadFromAuth();
|
||||
}
|
||||
|
||||
loadFromAuth() {
|
||||
if (window.PvZAuth) {
|
||||
const uData = window.PvZAuth.getUserData();
|
||||
if (uData.leaderboards) {
|
||||
this.records = { ...this.records, ...uData.leaderboards };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveToAuth() {
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
window.PvZAuth.saveUserData({
|
||||
leaderboards: this.records
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
submitScore(mode, score, detail = '') {
|
||||
if (!this.records[mode]) {
|
||||
this.records[mode] = [];
|
||||
}
|
||||
|
||||
let playerName = 'Игрок';
|
||||
if (window.PvZAuth) {
|
||||
playerName = window.PvZAuth.getCurrentUser();
|
||||
if (window.PvZAuth.isGuest()) playerName = 'Гость';
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Check if player already on this board
|
||||
const existing = this.records[mode].find(r => r.name === playerName);
|
||||
if (existing) {
|
||||
if (score > existing.score) {
|
||||
existing.score = score;
|
||||
existing.detail = detail || `${score} очков`;
|
||||
existing.date = today;
|
||||
}
|
||||
} else {
|
||||
this.records[mode].push({
|
||||
name: playerName,
|
||||
score,
|
||||
detail: detail || `${score} очков`,
|
||||
date: today
|
||||
});
|
||||
}
|
||||
|
||||
// Sort descending
|
||||
this.records[mode].sort((a, b) => b.score - a.score);
|
||||
// Keep top 10
|
||||
this.records[mode] = this.records[mode].slice(0, 10);
|
||||
this.saveToAuth();
|
||||
}
|
||||
|
||||
getEntries(mode) {
|
||||
return this.records[mode] || [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
// Garden Roguelike System: Seed Drafting, Artifact Relics, and Progressive Stage Escalation
|
||||
window.RoguelikeSystem = class RoguelikeSystem {
|
||||
constructor(gameEngine) {
|
||||
this.engine = gameEngine;
|
||||
this.activeRelics = [];
|
||||
this.currentFloor = 1;
|
||||
this.draftChoices = [];
|
||||
|
||||
// All available relics in Roguelike mode
|
||||
this.relicPool = [
|
||||
{
|
||||
id: 'relic_lightning',
|
||||
name: 'Громоотвод',
|
||||
icon: '⚡',
|
||||
desc: 'Каждые 7 секунд молния бьет в передового зомби, нанося 160 урона.',
|
||||
timer: 7
|
||||
},
|
||||
{
|
||||
id: 'relic_toxic_soil',
|
||||
name: 'Токсичный перегной',
|
||||
icon: '🧪',
|
||||
desc: 'Вся почва выделяет споры: все зомби замедлены на 15% и получают 6 ед. яда в секунду.'
|
||||
},
|
||||
{
|
||||
id: 'relic_sun_resonance',
|
||||
name: 'Солнечный резонанс',
|
||||
icon: '☀️',
|
||||
desc: 'Все подсолнухи производят на +15 больше солнца за каждую вспышку.'
|
||||
},
|
||||
{
|
||||
id: 'relic_iron_bark',
|
||||
name: 'Титановая кора',
|
||||
icon: '🛡️',
|
||||
desc: 'Все высаживаемые Стеноорехи моментально получают щит прочности +350 HP.'
|
||||
},
|
||||
{
|
||||
id: 'relic_vampire_sap',
|
||||
name: 'Вампирский сок',
|
||||
icon: '💖',
|
||||
desc: 'Каждое уничтожение зомби восстанавливает +40 HP самому раненому растению.'
|
||||
},
|
||||
{
|
||||
id: 'relic_fertilizer_boost',
|
||||
name: 'Гормон роста',
|
||||
icon: '🌿',
|
||||
desc: 'Все стрелковые растения атакуют на 20% быстрее.'
|
||||
}
|
||||
];
|
||||
|
||||
// Seed draft candidates
|
||||
this.plantPool = [
|
||||
'snowpea',
|
||||
'repeater',
|
||||
'wallnut',
|
||||
'potatomine',
|
||||
'chomper',
|
||||
'cherrybomb',
|
||||
'jalapeno',
|
||||
'fumeshroom'
|
||||
];
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.activeRelics = [];
|
||||
this.currentFloor = 1;
|
||||
this.draftChoices = [];
|
||||
}
|
||||
|
||||
hasRelic(id) {
|
||||
return this.activeRelics.some(r => r.id === id);
|
||||
}
|
||||
|
||||
addRelic(relic) {
|
||||
if (!this.hasRelic(relic.id)) {
|
||||
this.activeRelics.push({ ...relic, timer: relic.timer || 0 });
|
||||
if (this.engine && this.engine.achievements) {
|
||||
this.engine.achievements.trigger('roguelike_relic');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate 3 choices: seeds or relics
|
||||
generateDraftChoices() {
|
||||
const choices = [];
|
||||
const availableRelics = this.relicPool.filter(r => !this.hasRelic(r.id));
|
||||
|
||||
// 1-2 Relic choices
|
||||
if (availableRelics.length > 0) {
|
||||
const shuffledRelics = [...availableRelics].sort(() => 0.5 - Math.random());
|
||||
const r = shuffledRelics[0];
|
||||
choices.push({
|
||||
type: 'relic',
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
icon: r.icon,
|
||||
desc: r.desc,
|
||||
raw: r
|
||||
});
|
||||
}
|
||||
|
||||
// Seed choices: plants not currently in economy deck
|
||||
const currentCards = this.engine && this.engine.economy ? this.engine.economy.cards.map(c => c.id) : [];
|
||||
const availablePlants = this.plantPool.filter(p => !currentCards.includes(p));
|
||||
const shuffledPlants = [...availablePlants].sort(() => 0.5 - Math.random());
|
||||
|
||||
for (const pId of shuffledPlants.slice(0, 3 - choices.length)) {
|
||||
const def = this.engine.workshop ? this.engine.workshop.plantDefs[pId] : null;
|
||||
choices.push({
|
||||
type: 'seed',
|
||||
id: pId,
|
||||
name: def ? def.name : pId,
|
||||
icon: def ? def.icon : '🌱',
|
||||
desc: `Добавляет семя ${def ? def.name : pId} в колоду для текущего забега!`,
|
||||
raw: pId
|
||||
});
|
||||
}
|
||||
|
||||
// Fill remaining if needed
|
||||
while (choices.length < 3 && this.plantPool.length > 0) {
|
||||
const pId = this.plantPool[choices.length % this.plantPool.length];
|
||||
choices.push({
|
||||
type: 'seed',
|
||||
id: pId,
|
||||
name: pId,
|
||||
icon: '🌱',
|
||||
desc: `Дополнительное семя для текущего забега.`,
|
||||
raw: pId
|
||||
});
|
||||
}
|
||||
|
||||
this.draftChoices = choices;
|
||||
return choices;
|
||||
}
|
||||
|
||||
selectChoice(index) {
|
||||
const choice = this.draftChoices[index];
|
||||
if (!choice) return false;
|
||||
|
||||
if (choice.type === 'relic') {
|
||||
this.addRelic(choice.raw);
|
||||
} else if (choice.type === 'seed' && this.engine && this.engine.economy) {
|
||||
// Add seed to deck
|
||||
const newCard = this.engine.economy.allCards.find(c => c.id === choice.raw);
|
||||
if (newCard && !this.engine.economy.cards.some(c => c.id === newCard.id)) {
|
||||
this.engine.economy.cards.push({ ...newCard, timer: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
this.currentFloor++;
|
||||
return true;
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
// 1. Relic: Lightning strike
|
||||
const lightning = this.activeRelics.find(r => r.id === 'relic_lightning');
|
||||
if (lightning) {
|
||||
lightning.timer = (lightning.timer || 7) - dt;
|
||||
if (lightning.timer <= 0) {
|
||||
lightning.timer = 7;
|
||||
const target = gameState.zombies.find(z => z.health > 0 && !z.toRemove);
|
||||
if (target) {
|
||||
target.takeDamage(160);
|
||||
gameState.combat.createExplosionParticles(target.x, target.y);
|
||||
if (window.PvZAudio) window.PvZAudio.playStrike();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Relic: Toxic soil poison damage
|
||||
if (this.hasRelic('relic_toxic_soil')) {
|
||||
for (const z of gameState.zombies) {
|
||||
if (z.health > 0 && !z.toRemove) {
|
||||
z.takeDamage(6 * dt);
|
||||
z.speedModifier = Math.min(z.speedModifier, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw active relic badges in HUD
|
||||
draw(ctx) {
|
||||
if (this.activeRelics.length === 0) return;
|
||||
|
||||
ctx.save();
|
||||
let rx = 580;
|
||||
const ry = 52;
|
||||
for (const relic of this.activeRelics) {
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(rx, ry, 26, 26, 4);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffd700';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(relic.icon, rx + 13, ry + 13);
|
||||
rx += 30;
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
// Tree of Wisdom System & Secret Cheats Engine
|
||||
window.TreeSystem = class TreeSystem {
|
||||
constructor(economySystem) {
|
||||
this.economy = economySystem;
|
||||
this.height = 0; // In feet
|
||||
this.fertilizerBags = 0;
|
||||
this.fertilizerCost = 150; // coins per bag
|
||||
|
||||
// Active visual / gameplay cheats
|
||||
this.cheats = {
|
||||
mustache: false,
|
||||
future: false,
|
||||
daisies: false,
|
||||
dance: false,
|
||||
pinata: false
|
||||
};
|
||||
|
||||
// Wisdom unlocked at various heights
|
||||
this.wisdomList = [
|
||||
{
|
||||
height: 1,
|
||||
title: 'Первый росток',
|
||||
text: '«Приветствую, садовник! Корми меня древесным удобрением, и я открою тебе сокровенные тайны газона и секретные коды игры!»'
|
||||
},
|
||||
{
|
||||
height: 5,
|
||||
title: 'Секрет Крыши',
|
||||
text: '«На крыше дома прямые выстрелы горохом врезаются в скат черепицы! Сажай Капустопульты и Арбузопульты в Цветочные Горшки для навесной стрельбы!»'
|
||||
},
|
||||
{
|
||||
height: 10,
|
||||
title: 'Секретный код: MUSTACHE',
|
||||
text: '«Хочешь придать врагам благородный вид? Введи код mustache на клавиатуре — и все зомби наденут великолепные усы!»',
|
||||
cheat: 'mustache'
|
||||
},
|
||||
{
|
||||
height: 20,
|
||||
title: 'Секрет Тумана',
|
||||
text: '«В ночном тумане зомби скрыты от взора, пока не подойдут вплотную. Но сплэш-атаки Арбузопульта и пробивающие споры Дымогриба бьют сквозь мглу!»'
|
||||
},
|
||||
{
|
||||
height: 30,
|
||||
title: 'Секретный код: FUTURE',
|
||||
text: '«Зомби из будущего уже здесь! Введи код future, чтобы зомби получили стильные неоновые кибер-очки!»',
|
||||
cheat: 'future'
|
||||
},
|
||||
{
|
||||
height: 50,
|
||||
title: 'Секретный код: DAISIES',
|
||||
text: '«Смерть нежити может приносить радость. Введи код daisies — при падении зомби на земле будут расцветать прекрасные маргаритки!»',
|
||||
cheat: 'daisies'
|
||||
},
|
||||
{
|
||||
height: 75,
|
||||
title: 'Секрет Доктора Зомбосса',
|
||||
text: '«Огромный робот Зомбот доктора Зомбосса выпускает ледяные и огненные шары! Ледяной шар можно уничтожить ТОЛЬКО огненным Халапеньо!»'
|
||||
},
|
||||
{
|
||||
height: 100,
|
||||
title: 'Секретный код: DANCE',
|
||||
text: '«Ты вырастил меня до небес (100 футов)! Вот финальная тайна: код dance заставит зомби ритмично танцевать прямо во время атаки!»',
|
||||
cheat: 'dance'
|
||||
}
|
||||
];
|
||||
|
||||
this.loadFromAuth();
|
||||
this.setupKeyboardListener();
|
||||
}
|
||||
|
||||
loadFromAuth() {
|
||||
this.height = 0;
|
||||
this.cheats = {};
|
||||
if (window.PvZAuth) {
|
||||
const uData = window.PvZAuth.getUserData();
|
||||
if (uData.treeOfWisdom) {
|
||||
this.height = uData.treeOfWisdom.height || 0;
|
||||
this.cheats = { ...(uData.treeOfWisdom.cheats || {}) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveToAuth() {
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
window.PvZAuth.saveUserData({
|
||||
treeOfWisdom: {
|
||||
height: this.height,
|
||||
cheats: this.cheats
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
feedFertilizer() {
|
||||
if (!this.economy) return false;
|
||||
if (this.economy.totalCoins < this.fertilizerCost) return false;
|
||||
|
||||
this.economy.totalCoins -= this.fertilizerCost;
|
||||
this.economy.saveProfileData();
|
||||
this.height += 1;
|
||||
this.saveToAuth();
|
||||
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleCheat(cheatId) {
|
||||
if (this.cheats.hasOwnProperty(cheatId)) {
|
||||
this.cheats[cheatId] = !this.cheats[cheatId];
|
||||
this.saveToAuth();
|
||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||
return this.cheats[cheatId];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setupKeyboardListener() {
|
||||
let buffer = '';
|
||||
window.addEventListener('keydown', (e) => {
|
||||
// Avoid capturing input inside text boxes
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
const char = e.key.toLowerCase();
|
||||
if (/^[a-z]$/.test(char)) {
|
||||
buffer += char;
|
||||
if (buffer.length > 15) buffer = buffer.slice(-15);
|
||||
|
||||
for (const code of ['mustache', 'future', 'daisies', 'dance', 'pinata']) {
|
||||
if (buffer.endsWith(code)) {
|
||||
const state = this.toggleCheat(code);
|
||||
this.showCheatToast(code, state);
|
||||
buffer = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showCheatToast(code, active) {
|
||||
let toast = document.getElementById('achievement-toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'achievement-toast';
|
||||
toast.className = 'achievement-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="ach-toast-icon">✨</div>
|
||||
<div class="ach-toast-text">
|
||||
<div class="ach-toast-subtitle">ЧИТ-КОД АКТИВИРОВАН!</div>
|
||||
<div class="ach-toast-title">${code.toUpperCase()}: ${active ? 'ВКЛЮЧЕН ✅' : 'ВЫКЛЮЧЕН ❌'}</div>
|
||||
</div>
|
||||
`;
|
||||
toast.classList.add('visible');
|
||||
setTimeout(() => toast.classList.remove('visible'), 3000);
|
||||
}
|
||||
|
||||
// Get current tree level wisdom
|
||||
getCurrentWisdom() {
|
||||
const unlocked = this.wisdomList.filter(w => this.height >= w.height);
|
||||
return unlocked.length > 0 ? unlocked[unlocked.length - 1] : this.wisdomList[0];
|
||||
}
|
||||
|
||||
getAllUnlockedWisdom() {
|
||||
return this.wisdomList.filter(w => this.height >= w.height);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
// Wave Management, Level Progression & Game Modes System
|
||||
window.WaveSystem = class WaveSystem {
|
||||
constructor() {
|
||||
this.mode = 'adventure'; // 'adventure', 'survival', 'bowling', 'izombie'
|
||||
this.level = 1;
|
||||
this.biome = 'day';
|
||||
|
||||
this.currentWave = 0;
|
||||
this.totalWaves = 4;
|
||||
this.waveTimer = 0;
|
||||
this.spawnQueue = [];
|
||||
this.waveState = 'starting';
|
||||
this.bannerText = '';
|
||||
this.bannerTimer = 0;
|
||||
this.isVictory = false;
|
||||
|
||||
// Endless Survival tracking
|
||||
this.survivalFlags = 0;
|
||||
}
|
||||
|
||||
setLevel(level = 1, mode = 'adventure') {
|
||||
this.level = level;
|
||||
this.mode = mode;
|
||||
|
||||
if (mode === 'bowling') {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = 5;
|
||||
} else if (mode === 'izombie') {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = 1;
|
||||
} else if (mode === 'boss') {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 1;
|
||||
} else if (mode === 'bossrush') {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 3;
|
||||
} else if (mode === 'roguelike') {
|
||||
this.totalWaves = 2;
|
||||
this.biome = (level <= 2) ? 'day' : (level <= 4 ? 'night' : (level <= 6 ? 'pool' : 'roof'));
|
||||
} else if (mode === 'survival') {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = 999;
|
||||
} else {
|
||||
// Adventure Campaign Mode with CampaignSystem
|
||||
if (window.CampaignSystem) {
|
||||
const campLvl = window.CampaignSystem.getLevel(level);
|
||||
this.biome = campLvl.biome || 'day';
|
||||
this.totalWaves = campLvl.waves || 4;
|
||||
if (campLvl.isBoss) {
|
||||
this.mode = 'boss';
|
||||
}
|
||||
} else {
|
||||
if (level <= 5) {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = (level === 1) ? 3 : 4;
|
||||
} else if (level <= 10) {
|
||||
this.biome = 'night';
|
||||
this.totalWaves = 4;
|
||||
} else if (level <= 15) {
|
||||
this.biome = 'pool';
|
||||
this.totalWaves = 4;
|
||||
} else if (level <= 20) {
|
||||
this.biome = 'fog';
|
||||
this.totalWaves = 4;
|
||||
} else if (level < 25) {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 4;
|
||||
} else {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 1;
|
||||
this.mode = 'boss';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentWave = 0;
|
||||
this.waveTimer = 0;
|
||||
this.spawnQueue = [];
|
||||
this.waveState = 'starting';
|
||||
this.survivalFlags = 0; // reset per-game, not per-session
|
||||
|
||||
let advBanner = `УРОВЕНЬ ${this.level}`;
|
||||
if (window.CampaignSystem && this.mode === 'adventure') {
|
||||
const campFmt = window.CampaignSystem.formatLevel(this.level);
|
||||
advBanner = `${campFmt.worldIcon} ЭТАП ${campFmt.code}: ${campFmt.title.toUpperCase()}`;
|
||||
}
|
||||
|
||||
this.bannerText = this.mode === 'bowling'
|
||||
? '🎳 БОУЛИНГ СТЕНООРЕХАМИ!'
|
||||
: (this.mode === 'izombie'
|
||||
? '🧠 СЪЕШЬТЕ ВСЕ ПОДСОЛНУХИ!'
|
||||
: (this.mode === 'bossrush'
|
||||
? '⚡ BOSS RUSH: ЧЕРЕДА БОССОВ!'
|
||||
: (this.mode === 'boss'
|
||||
? '👑 БИТВА: ДОКТОР ЗОМБОСС!'
|
||||
: (this.mode === 'roguelike'
|
||||
? `🎲 РОГАЛИК: ЭТАЖ ${this.level}`
|
||||
: (this.mode === 'survival' ? '⚔️ БЕСКОНЕЧНОЕ ВЫЖИВАНИЕ' : advBanner)))));
|
||||
this.bannerTimer = 3.5;
|
||||
this.isVictory = false;
|
||||
}
|
||||
|
||||
startNextWave(gameState) {
|
||||
this.currentWave++;
|
||||
this.waveState = 'spawning';
|
||||
|
||||
if (this.mode === 'boss') {
|
||||
this.bannerText = '👑 ДОКТОР ЗОМБОСС АТАКУЕТ!';
|
||||
this.bannerTimer = 3.5;
|
||||
if (gameState.boss) {
|
||||
gameState.boss.startFight();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'bossrush') {
|
||||
if (this.currentWave === 1) {
|
||||
this.bannerText = '⚡ ВОЛНА 1: ОРДА ТЯЖЕЛОВЕСОВ И СПРИНТЕРОВ!';
|
||||
this.bannerTimer = 3.5;
|
||||
this.spawnQueue = [];
|
||||
const count = 7;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['football', 'buckethead', 'conehead'];
|
||||
const t = types[i % types.length];
|
||||
this.spawnQueue.push({
|
||||
delay: 2.0 + i * 2.2,
|
||||
type: t,
|
||||
row: i % gameState.grid.rows
|
||||
});
|
||||
}
|
||||
} else if (this.currentWave === 2) {
|
||||
this.bannerText = '⚡ ВОЛНА 2: ЭЛИТНЫЙ ЗОМБИ-СПЕЦНАЗ!';
|
||||
this.bannerTimer = 3.5;
|
||||
this.spawnQueue = [];
|
||||
const count = 9;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['newspaper', 'polevaulter', 'buckethead', 'football'];
|
||||
const t = types[i % types.length];
|
||||
this.spawnQueue.push({
|
||||
delay: 1.8 + i * 1.8,
|
||||
type: t,
|
||||
row: (i * 2) % gameState.grid.rows
|
||||
});
|
||||
}
|
||||
} else if (this.currentWave === 3) {
|
||||
this.bannerText = '👑 ФИНАЛЬНЫЙ БОСС: Д-Р ЗОМБОСС & ЗОМБОТ!';
|
||||
this.bannerTimer = 4.0;
|
||||
if (gameState.boss) {
|
||||
gameState.boss.startFight();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'bowling') {
|
||||
this.bannerText = `ВОЛНА ${this.currentWave} / ${this.totalWaves}`;
|
||||
this.bannerTimer = 2.5;
|
||||
const count = 4 + this.currentWave * 2;
|
||||
this.spawnQueue = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['normal', 'conehead', 'newspaper', 'polevaulter', 'buckethead'];
|
||||
const t = types[Math.floor(Math.random() * Math.min(types.length, 2 + this.currentWave))];
|
||||
this.spawnQueue.push({
|
||||
delay: 2.0 + i * 2.5,
|
||||
type: t,
|
||||
row: Math.floor(Math.random() * gameState.grid.rows)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'survival') {
|
||||
this.survivalFlags++;
|
||||
this.bannerText = `🚩 ФЛАГ ${this.survivalFlags}!`;
|
||||
this.bannerTimer = 3.0;
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
|
||||
const waveSize = 8 + this.survivalFlags * 3;
|
||||
this.spawnQueue = [];
|
||||
const types = ['normal', 'conehead', 'buckethead', 'polevaulter', 'newspaper', 'football'];
|
||||
for (let i = 0; i < waveSize; i++) {
|
||||
this.spawnQueue.push({
|
||||
delay: 1.5 + i * (22 / waveSize),
|
||||
type: types[Math.floor(Math.random() * types.length)],
|
||||
row: Math.floor(Math.random() * gameState.grid.rows)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'roguelike') {
|
||||
this.bannerText = `🎲 ЭТАЖ ${this.level} - ВОЛНА ${this.currentWave} / ${this.totalWaves}`;
|
||||
this.bannerTimer = 2.8;
|
||||
const count = 3 + this.level * 2 + this.currentWave * 2;
|
||||
this.spawnQueue = [];
|
||||
const types = ['normal', 'conehead', 'newspaper', 'polevaulter', 'buckethead', 'football'];
|
||||
const maxTypeIndex = Math.min(types.length - 1, Math.floor(this.level / 2) + this.currentWave);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = types[Math.floor(Math.random() * (maxTypeIndex + 1))];
|
||||
this.spawnQueue.push({
|
||||
delay: 1.5 + i * (14 / count),
|
||||
type: t,
|
||||
row: this.biome === 'pool' && Math.random() < 0.35 ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Adventure Campaign Waves
|
||||
if (this.currentWave === 1) {
|
||||
this.bannerText = 'ПЕРВЫЕ ЗОМБИ...';
|
||||
this.bannerTimer = 3.0;
|
||||
this.spawnQueue = [
|
||||
{ delay: 3.0, type: 'normal', row: this.randomRow(gameState) },
|
||||
{ delay: 8.0, type: 'normal', row: this.randomRow(gameState) },
|
||||
{ delay: 14.0, type: 'conehead', row: this.randomRow(gameState) },
|
||||
{ delay: 19.0, type: 'normal', row: this.randomRow(gameState) }
|
||||
];
|
||||
} else if (this.currentWave === 2) {
|
||||
this.bannerText = 'ВОЛНА 2';
|
||||
this.bannerTimer = 2.5;
|
||||
const isWaterBiome = this.biome === 'pool' || this.biome === 'fog';
|
||||
const typeList = isWaterBiome
|
||||
? ['normal', 'duckytube', 'conehead', 'duckytube', 'polevaulter']
|
||||
: ['normal', 'newspaper', 'polevaulter', 'conehead', 'normal'];
|
||||
this.spawnQueue = typeList.map((t, idx) => ({
|
||||
delay: 2.5 + idx * 3.5,
|
||||
type: t,
|
||||
row: t === 'duckytube' ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState)
|
||||
}));
|
||||
} else if (this.currentWave === 3) {
|
||||
this.bannerText = '⚠️ БОЛЬШАЯ ВОЛНА ЗОМБИ!';
|
||||
this.bannerTimer = 4.0;
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
|
||||
const isWaterBiome = this.biome === 'pool' || this.biome === 'fog';
|
||||
this.spawnQueue = [
|
||||
{ delay: 2.0, type: 'flag', row: isWaterBiome ? 1 : 2 },
|
||||
{ delay: 2.5, type: 'conehead', row: 0 },
|
||||
{ delay: 2.8, type: isWaterBiome ? 'duckytube' : 'newspaper', row: isWaterBiome ? 2 : 1 },
|
||||
{ delay: 3.2, type: 'football', row: isWaterBiome ? 4 : 3 },
|
||||
{ delay: 3.5, type: isWaterBiome ? 'duckytube' : 'polevaulter', row: isWaterBiome ? 3 : 4 },
|
||||
{ delay: 7.0, type: 'buckethead', row: this.randomRow(gameState) },
|
||||
{ delay: 9.0, type: isWaterBiome ? 'duckytube' : 'normal', row: isWaterBiome ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState) },
|
||||
{ delay: 12.0, type: 'conehead', row: this.randomRow(gameState) }
|
||||
];
|
||||
|
||||
// Night mode: Zombies rise from gravestones!
|
||||
if (this.biome === 'night') {
|
||||
for (const grave of gameState.grid.graves) {
|
||||
setTimeout(() => {
|
||||
if (gameState.isRunning) {
|
||||
const bounds = gameState.grid.getCellBounds(grave.col, grave.row);
|
||||
const z = new window.Zombie(grave.row, 'normal', bounds.centerX);
|
||||
z.y = bounds.centerY - 5;
|
||||
gameState.zombies.push(z);
|
||||
gameState.combat.createExplosionParticles(bounds.centerX, bounds.centerY);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
} else if (this.currentWave >= 4) {
|
||||
this.bannerText = '☠️ ПОСЛЕДНЯЯ ВОЛНА!';
|
||||
this.bannerTimer = 4.5;
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
|
||||
const isWaterBiome = this.biome === 'pool' || this.biome === 'fog';
|
||||
this.spawnQueue = [
|
||||
{ delay: 2.0, type: 'flag', row: isWaterBiome ? 1 : 2 },
|
||||
{ delay: 2.4, type: 'football', row: 0 },
|
||||
{ delay: 2.8, type: isWaterBiome ? 'duckytube' : 'buckethead', row: isWaterBiome ? 2 : 1 },
|
||||
{ delay: 3.2, type: 'polevaulter', row: isWaterBiome ? 4 : 2 },
|
||||
{ delay: 3.5, type: isWaterBiome ? 'duckytube' : 'newspaper', row: isWaterBiome ? 3 : 3 },
|
||||
{ delay: 3.8, type: 'conehead', row: isWaterBiome ? 5 : 4 },
|
||||
{ delay: 6.5, type: 'buckethead', row: this.randomRow(gameState) },
|
||||
{ delay: 8.0, type: 'football', row: this.randomRow(gameState) },
|
||||
{ delay: 10.0, type: isWaterBiome ? 'duckytube' : 'conehead', row: isWaterBiome ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState) },
|
||||
{ delay: 12.0, type: 'normal', row: this.randomRow(gameState) }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
randomRow(gameState) {
|
||||
return Math.floor(Math.random() * gameState.grid.rows);
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
if (this.bannerTimer > 0) {
|
||||
this.bannerTimer -= dt;
|
||||
}
|
||||
|
||||
if (this.mode === 'izombie') return;
|
||||
|
||||
if (this.waveState === 'starting') {
|
||||
this.waveTimer += dt;
|
||||
if (this.waveTimer >= 3.5) {
|
||||
this.waveTimer = 0;
|
||||
this.startNextWave(gameState);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'boss') return; // Boss mode fight is updated by boss system
|
||||
|
||||
if (this.waveState === 'spawning') {
|
||||
this.waveTimer += dt;
|
||||
|
||||
for (let i = this.spawnQueue.length - 1; i >= 0; i--) {
|
||||
const item = this.spawnQueue[i];
|
||||
if (this.waveTimer >= item.delay) {
|
||||
gameState.spawnZombie(item.row, item.type);
|
||||
this.spawnQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// In bossrush wave 3, victory is triggered when boss is defeated, not immediately
|
||||
const isBossRushFight = this.mode === 'bossrush' && this.currentWave === 3;
|
||||
if (this.spawnQueue.length === 0 && gameState.zombies.length === 0 && !isBossRushFight) {
|
||||
if (this.currentWave >= this.totalWaves && this.mode !== 'survival') {
|
||||
this.waveState = 'completed';
|
||||
this.isVictory = true;
|
||||
gameState.triggerVictory();
|
||||
} else {
|
||||
this.waveState = 'waiting';
|
||||
this.waveTimer = 0;
|
||||
}
|
||||
}
|
||||
} else if (this.waveState === 'waiting') {
|
||||
this.waveTimer += dt;
|
||||
if (this.waveTimer >= 4.0) {
|
||||
this.waveTimer = 0;
|
||||
this.startNextWave(gameState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
// 1. Progress Bar in Top Right
|
||||
const barX = 685;
|
||||
const barY = 22;
|
||||
const barW = 115;
|
||||
const barH = 22;
|
||||
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX, barY, barW, barH, 4);
|
||||
ctx.fill();
|
||||
|
||||
const progress = this.mode === 'survival'
|
||||
? 1.0
|
||||
: Math.min(1, (this.currentWave - 1 + (this.waveState === 'waiting' || this.waveState === 'completed' ? 1 : 0.5)) / this.totalWaves);
|
||||
|
||||
ctx.fillStyle = '#4caf50';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX + barW * (1 - progress), barY + 2, barW * progress - 2, barH - 4, 3);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#8d6e63';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(barX, barY, barW, barH);
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const label = this.mode === 'survival'
|
||||
? `ФЛАГ ${this.survivalFlags}`
|
||||
: (this.mode === 'roguelike' ? `ЭТАЖ ${this.level} (${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves})` : `ВОЛНА ${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves}`);
|
||||
ctx.fillText(label, barX + barW / 2, barY + 16);
|
||||
|
||||
// 2. Center Banner Display
|
||||
if (this.bannerTimer > 0 && this.bannerText) {
|
||||
ctx.save();
|
||||
const alpha = Math.min(1, this.bannerTimer);
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
|
||||
ctx.fillRect(0, 240, 900, 70);
|
||||
|
||||
ctx.fillStyle = this.bannerText.includes('БОЛЬШАЯ') || this.bannerText.includes('ПОСЛЕДНЯЯ')
|
||||
? '#ff1744'
|
||||
: '#ffeb3b';
|
||||
ctx.font = 'bold 30px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowColor = '#000';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillText(this.bannerText, 450, 285);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
// Workshop System (Crafting, 3-Tier Plant Upgrades, and Passive Perks)
|
||||
window.WorkshopSystem = class WorkshopSystem {
|
||||
constructor(economy) {
|
||||
this.economy = economy;
|
||||
|
||||
// Default Materials
|
||||
this.materials = {
|
||||
bioHumus: 10,
|
||||
scrapMetal: 5,
|
||||
sunEssence: 20,
|
||||
crystals: 0
|
||||
};
|
||||
|
||||
// Plant Upgrade Levels (1, 2, 3)
|
||||
this.plantLevels = {
|
||||
peashooter: 1,
|
||||
sunflower: 1,
|
||||
wallnut: 1,
|
||||
potatomine: 1,
|
||||
repeater: 1,
|
||||
snowpea: 1,
|
||||
chomper: 1
|
||||
};
|
||||
|
||||
// Passive Perks (0, 1, 2, 3)
|
||||
this.perks = {
|
||||
magnetism: 0,
|
||||
startSun: 0,
|
||||
fastCool: 0,
|
||||
hardyRoots: 0,
|
||||
luckyDrop: 0
|
||||
};
|
||||
|
||||
// Flying loot items on canvas
|
||||
this.floatingLoot = [];
|
||||
|
||||
// Upgrade Definitions
|
||||
this.plantDefs = {
|
||||
peashooter: {
|
||||
name: 'Горохострел',
|
||||
icon: '🌱',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: 'Урон 20, интервал 1.4с' },
|
||||
2: { title: 'Скорострел', desc: 'Урон 25, атака +20%, крит 15% (x2 урон)', cost: { coins: 150, bioHumus: 8 } },
|
||||
3: { title: 'Плазменный стрелок', desc: 'Пробивает 1-го зомби насквозь и ранит следующего', cost: { coins: 400, bioHumus: 20, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
sunflower: {
|
||||
name: 'Подсолнух',
|
||||
icon: '🌻',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: 'Дает 25 ☀️ каждые 12с' },
|
||||
2: { title: 'Крупный подсолнух', desc: 'Дает 35 ☀️ каждые 10с', cost: { coins: 120, sunEssence: 15 } },
|
||||
3: { title: 'Благодатный свет', desc: 'При генерации лечит +30 HP соседним растениям', cost: { coins: 350, sunEssence: 35, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
wallnut: {
|
||||
name: 'Стеноорех',
|
||||
icon: '🌰',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: '400 HP' },
|
||||
2: { title: 'Каменный монолит', desc: '700 HP + 15% снижения входящего урона', cost: { coins: 150, scrapMetal: 10 } },
|
||||
3: { title: 'Шипованный бастион', desc: '1000 HP + возврат 30% урона кусающим зомби', cost: { coins: 400, scrapMetal: 25, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
potatomine: {
|
||||
name: 'Картофельная мина',
|
||||
icon: '🥔',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: 'Взведение 15с, взрыв в клетке' },
|
||||
2: { title: 'Быстрый фитиль', desc: 'Взведение всего за 6 секунд!', cost: { coins: 120, bioHumus: 8, scrapMetal: 5 } },
|
||||
3: { title: 'Атомный Spudow', desc: 'Взрыв 3x3 + горящая лужа лавы на 5с', cost: { coins: 350, bioHumus: 18, scrapMetal: 15, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
repeater: {
|
||||
name: 'Повторитель',
|
||||
icon: '🌿',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: '2 горошины за выстрел' },
|
||||
2: { title: 'Тройной залп', desc: '2 горошины + 20% шанс выпустить 3-ю', cost: { coins: 250, bioHumus: 15, sunEssence: 10 } },
|
||||
3: { title: 'Огненный повторитель', desc: 'Горошины пылают и наносят сплэш-урон', cost: { coins: 600, bioHumus: 30, sunEssence: 25, crystals: 2 } }
|
||||
}
|
||||
},
|
||||
snowpea: {
|
||||
name: 'Ледяной горох',
|
||||
icon: '❄️',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: 'Замедление 4с, урон 20' },
|
||||
2: { title: 'Вечная мерзлота', desc: 'Холод 6с + замедляет соседей сзади', cost: { coins: 250, bioHumus: 15, crystals: 1 } },
|
||||
3: { title: 'Ледяная глыба', desc: 'Шанс 15% полностью обратить зомби в лед на 2с', cost: { coins: 600, bioHumus: 30, crystals: 3 } }
|
||||
}
|
||||
},
|
||||
chomper: {
|
||||
name: 'Зубастик',
|
||||
icon: '🌺',
|
||||
tiers: {
|
||||
1: { title: 'Базовый', desc: 'Заглатывает зомби, жевание 30с' },
|
||||
2: { title: 'Острые клыки', desc: 'Скорость жевания ускорена до 14с', cost: { coins: 200, bioHumus: 15, scrapMetal: 10 } },
|
||||
3: { title: 'Двойной укус', desc: 'Способен проглотить сразу двух зомби подряд!', cost: { coins: 500, bioHumus: 30, scrapMetal: 20, crystals: 1 } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.perkDefs = {
|
||||
magnetism: {
|
||||
name: 'Магнитный газон',
|
||||
icon: '🧲',
|
||||
desc: 'Автоматически притягивает монеты, солнце и материалы',
|
||||
tiers: {
|
||||
1: { title: 'Ур. 1', desc: 'Радиус сбора +40px', cost: { coins: 150, scrapMetal: 5 } },
|
||||
2: { title: 'Ур. 2', desc: 'Радиус сбора +90px', cost: { coins: 300, scrapMetal: 12 } },
|
||||
3: { title: 'Ур. 3', desc: 'Полный автосбор со всего экрана!', cost: { coins: 600, scrapMetal: 25, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
startSun: {
|
||||
name: 'Стартовый капитал',
|
||||
icon: '☀️',
|
||||
desc: 'Дополнительное солнце в начале каждого раунда',
|
||||
tiers: {
|
||||
1: { title: 'Ур. 1', desc: '+50 начального солнца', cost: { coins: 150, sunEssence: 15 } },
|
||||
2: { title: 'Ур. 2', desc: '+100 начального солнца', cost: { coins: 350, sunEssence: 30 } },
|
||||
3: { title: 'Ур. 3', desc: '+150 начального солнца', cost: { coins: 700, sunEssence: 60, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
fastCool: {
|
||||
name: 'Ускоренный полив',
|
||||
icon: '⏳',
|
||||
desc: 'Сокращает время перезарядки всех пакетов семян',
|
||||
tiers: {
|
||||
1: { title: 'Ур. 1', desc: 'Перезарядка -10%', cost: { coins: 200, bioHumus: 10 } },
|
||||
2: { title: 'Ур. 2', desc: 'Перезарядка -20%', cost: { coins: 450, bioHumus: 25 } },
|
||||
3: { title: 'Ур. 3', desc: 'Перезарядка -30%', cost: { coins: 900, bioHumus: 50, crystals: 2 } }
|
||||
}
|
||||
},
|
||||
hardyRoots: {
|
||||
name: 'Упорные корни',
|
||||
icon: '🛡️',
|
||||
desc: 'Увеличивает максимальное здоровье всех растений на газоне',
|
||||
tiers: {
|
||||
1: { title: 'Ур. 1', desc: '+20% к запасу здоровья', cost: { coins: 180, scrapMetal: 10, bioHumus: 10 } },
|
||||
2: { title: 'Ур. 2', desc: '+35% к запасу здоровья', cost: { coins: 400, scrapMetal: 20, bioHumus: 20 } },
|
||||
3: { title: 'Ур. 3', desc: '+50% к запасу здоровья', cost: { coins: 800, scrapMetal: 40, bioHumus: 40, crystals: 1 } }
|
||||
}
|
||||
},
|
||||
luckyDrop: {
|
||||
name: 'Золотая рука',
|
||||
icon: '🍀',
|
||||
desc: 'Повышает шанс выпадения золотых монет и редких материалов',
|
||||
tiers: {
|
||||
1: { title: 'Ур. 1', desc: 'Шанс дропа +30%', cost: { coins: 200, crystals: 1 } },
|
||||
2: { title: 'Ур. 2', desc: 'Шанс дропа +60%', cost: { coins: 500, crystals: 2 } },
|
||||
3: { title: 'Ур. 3', desc: 'Шанс дропа +100%', cost: { coins: 1000, crystals: 4 } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.loadFromAuth();
|
||||
}
|
||||
|
||||
loadFromAuth() {
|
||||
const defaultMaterials = { bioHumus: 10, scrapMetal: 5, sunEssence: 20, crystals: 0 };
|
||||
const defaultPlantLevels = { peashooter: 1, sunflower: 1, wallnut: 1, potatomine: 1, repeater: 1, snowpea: 1, chomper: 1 };
|
||||
const defaultPerks = { magnetism: 0, startSun: 0, fastCool: 0, hardyRoots: 0, luckyDrop: 0 };
|
||||
|
||||
if (window.PvZAuth) {
|
||||
const data = window.PvZAuth.getUserData();
|
||||
this.materials = { ...defaultMaterials, ...(data.materials || {}) };
|
||||
this.plantLevels = { ...defaultPlantLevels, ...(data.plantLevels || {}) };
|
||||
this.perks = { ...defaultPerks, ...(data.perks || {}) };
|
||||
} else {
|
||||
this.materials = { ...defaultMaterials };
|
||||
this.plantLevels = { ...defaultPlantLevels };
|
||||
this.perks = { ...defaultPerks };
|
||||
}
|
||||
}
|
||||
|
||||
saveToAuth() {
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
window.PvZAuth.saveUserData({
|
||||
materials: this.materials,
|
||||
plantLevels: this.plantLevels,
|
||||
perks: this.perks,
|
||||
coins: this.economy ? this.economy.totalCoins : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getPlantLevel(plantType) {
|
||||
return this.plantLevels[plantType] || 1;
|
||||
}
|
||||
|
||||
getPerkLevel(perkId) {
|
||||
return this.perks[perkId] || 0;
|
||||
}
|
||||
|
||||
canAffordCost(cost) {
|
||||
if (!cost) return false;
|
||||
const coins = this.economy ? this.economy.totalCoins : 0;
|
||||
if (cost.coins && coins < cost.coins) return false;
|
||||
if (cost.bioHumus && this.materials.bioHumus < cost.bioHumus) return false;
|
||||
if (cost.scrapMetal && this.materials.scrapMetal < cost.scrapMetal) return false;
|
||||
if (cost.sunEssence && this.materials.sunEssence < cost.sunEssence) return false;
|
||||
if (cost.crystals && this.materials.crystals < cost.crystals) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
payCost(cost) {
|
||||
if (!this.canAffordCost(cost)) return false;
|
||||
if (cost.coins && this.economy) this.economy.addCoins(-cost.coins);
|
||||
if (cost.bioHumus) this.materials.bioHumus -= cost.bioHumus;
|
||||
if (cost.scrapMetal) this.materials.scrapMetal -= cost.scrapMetal;
|
||||
if (cost.sunEssence) this.materials.sunEssence -= cost.sunEssence;
|
||||
if (cost.crystals) this.materials.crystals -= cost.crystals;
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
|
||||
upgradePlant(plantId) {
|
||||
const current = this.getPlantLevel(plantId);
|
||||
if (current >= 3) return false;
|
||||
|
||||
const nextTier = current + 1;
|
||||
const def = this.plantDefs[plantId];
|
||||
if (!def || !def.tiers[nextTier]) return false;
|
||||
|
||||
const cost = def.tiers[nextTier].cost;
|
||||
if (this.payCost(cost)) {
|
||||
this.plantLevels[plantId] = nextTier;
|
||||
this.saveToAuth();
|
||||
if (nextTier === 3 && this.economy && this.economy.engine && this.economy.engine.achievements) {
|
||||
this.economy.engine.achievements.trigger('tier3_unlocked');
|
||||
}
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
upgradePerk(perkId) {
|
||||
const current = this.getPerkLevel(perkId);
|
||||
if (current >= 3) return false;
|
||||
|
||||
const nextTier = current + 1;
|
||||
const def = this.perkDefs[perkId];
|
||||
if (!def || !def.tiers[nextTier]) return false;
|
||||
|
||||
const cost = def.tiers[nextTier].cost;
|
||||
if (this.payCost(cost)) {
|
||||
this.perks[perkId] = nextTier;
|
||||
this.saveToAuth();
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
addMaterial(type, amount = 1) {
|
||||
if (this.materials[type] !== undefined) {
|
||||
this.materials[type] += amount;
|
||||
this.saveToAuth();
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn visual drop on field
|
||||
spawnLoot(x, y, type) {
|
||||
this.floatingLoot.push({
|
||||
x,
|
||||
y,
|
||||
startY: y,
|
||||
targetY: Math.min(y + 25, 550),
|
||||
type,
|
||||
animTime: 0,
|
||||
vy: -120,
|
||||
collected: false,
|
||||
alpha: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
// Check loot collection and animation
|
||||
const mousePos = gameState.hoverCell ? gameState.grid.getCellBounds(gameState.hoverCell.col, gameState.hoverCell.row) : null;
|
||||
const magLevel = this.getPerkLevel('magnetism');
|
||||
const magnetRadius = magLevel === 3 ? 9999 : (magLevel === 2 ? 140 : (magLevel === 1 ? 80 : 35));
|
||||
|
||||
for (let i = this.floatingLoot.length - 1; i >= 0; i--) {
|
||||
const loot = this.floatingLoot[i];
|
||||
loot.animTime += dt;
|
||||
|
||||
if (!loot.collected) {
|
||||
if (loot.vy < 0) {
|
||||
loot.y += loot.vy * dt;
|
||||
loot.vy += 300 * dt;
|
||||
}
|
||||
|
||||
// Magnetism collection
|
||||
if (gameState.hoverCell) {
|
||||
const bounds = gameState.grid.getCellBounds(gameState.hoverCell.col, gameState.hoverCell.row);
|
||||
const dist = Math.hypot(bounds.centerX - loot.x, bounds.centerY - loot.y);
|
||||
if (dist <= magnetRadius) {
|
||||
this.collectLoot(loot);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loot.y -= 100 * dt;
|
||||
loot.alpha -= 2.0 * dt;
|
||||
if (loot.alpha <= 0) {
|
||||
this.floatingLoot.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectLoot(loot) {
|
||||
if (loot.collected) return;
|
||||
loot.collected = true;
|
||||
this.addMaterial(loot.type, 1);
|
||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
for (const loot of this.floatingLoot) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, loot.alpha);
|
||||
ctx.translate(loot.x, loot.y);
|
||||
|
||||
// Bounce bobbing
|
||||
const bob = Math.sin(loot.animTime * 6) * 3;
|
||||
ctx.translate(0, bob);
|
||||
|
||||
// Glow circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 16, 0, Math.PI * 2);
|
||||
ctx.fillStyle = loot.type === 'crystals' ? 'rgba(0, 229, 255, 0.4)' :
|
||||
(loot.type === 'sunEssence' ? 'rgba(255, 235, 59, 0.4)' :
|
||||
(loot.type === 'scrapMetal' ? 'rgba(158, 158, 158, 0.4)' : 'rgba(139, 195, 74, 0.4)'));
|
||||
ctx.fill();
|
||||
|
||||
// Icon
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const icon = loot.type === 'crystals' ? '💎' :
|
||||
(loot.type === 'sunEssence' ? '☀️' :
|
||||
(loot.type === 'scrapMetal' ? '⚙️' : '🧪'));
|
||||
ctx.fillText(icon, 0, 1);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,538 @@
|
||||
// Zen Garden System: Idle Potted Plant Farming, AFK Coins, Care & Stinky the Snail
|
||||
window.ZenGardenSystem = class ZenGardenSystem {
|
||||
constructor(economy) {
|
||||
this.economy = economy;
|
||||
|
||||
// Default Zen Garden state
|
||||
this.pots = [
|
||||
{ id: 0, plantType: 'sunflower', stage: 2, thirstTimer: 0, needsFertilizer: false, happyTimer: 60, coinTimer: 5 },
|
||||
{ id: 1, plantType: 'peashooter', stage: 1, thirstTimer: 15, needsFertilizer: false, happyTimer: 45, coinTimer: 8 },
|
||||
{ id: 2, plantType: 'wallnut', stage: 0, thirstTimer: 0, needsFertilizer: true, happyTimer: 0, coinTimer: 0 }, // Sprout
|
||||
{ id: 3, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 0 },
|
||||
{ id: 4, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 0 },
|
||||
{ id: 5, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 0 },
|
||||
{ id: 6, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 0 },
|
||||
{ id: 7, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 0 }
|
||||
];
|
||||
|
||||
this.fertilizerCount = 5;
|
||||
this.chocolateCount = 3;
|
||||
|
||||
// Snail state
|
||||
this.snail = {
|
||||
x: 100,
|
||||
targetX: 300,
|
||||
dir: 1,
|
||||
isAwake: true,
|
||||
sleepTimer: 120, // seconds until sleep
|
||||
boostTimer: 0 // chocolate boost
|
||||
};
|
||||
|
||||
// Dropped coins in Zen Garden
|
||||
this.gardenCoins = [];
|
||||
|
||||
// Offline progression tracking
|
||||
this.lastVisit = Date.now();
|
||||
this.offlineEarned = 0;
|
||||
|
||||
this.loadFromAuth();
|
||||
this.calculateOfflineEarnings();
|
||||
}
|
||||
|
||||
loadFromAuth() {
|
||||
const defaultPots = [
|
||||
{ id: 1, plantType: 'sunflower', stage: 2, watered: true, fertilized: true, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 2, plantType: 'peashooter', stage: 1, watered: true, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 3, plantType: null, stage: 0, watered: false, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 4, plantType: null, stage: 0, watered: false, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 5, plantType: null, stage: 0, watered: false, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 6, plantType: null, stage: 0, watered: false, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 7, plantType: null, stage: 0, watered: false, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 },
|
||||
{ id: 8, plantType: null, stage: 0, watered: false, fertilized: false, needWaterTime: 0, matureTime: 0, dropsCollected: 0 }
|
||||
];
|
||||
this.pots = JSON.parse(JSON.stringify(defaultPots));
|
||||
this.fertilizerCount = 5;
|
||||
this.chocolateCount = 2;
|
||||
this.snail = { name: 'Стинки', active: false, speed: 60, boostTimer: 0, x: 50, direction: 1 };
|
||||
this.lastVisit = Date.now();
|
||||
|
||||
if (window.PvZAuth) {
|
||||
const uData = window.PvZAuth.getUserData();
|
||||
if (uData.zenGarden) {
|
||||
if (uData.zenGarden.pots && Array.isArray(uData.zenGarden.pots) && uData.zenGarden.pots.length > 0) {
|
||||
this.pots = JSON.parse(JSON.stringify(uData.zenGarden.pots));
|
||||
}
|
||||
if (uData.zenGarden.fertilizerCount !== undefined) this.fertilizerCount = uData.zenGarden.fertilizerCount;
|
||||
if (uData.zenGarden.chocolateCount !== undefined) this.chocolateCount = uData.zenGarden.chocolateCount;
|
||||
if (uData.zenGarden.snail) this.snail = { ...this.snail, ...uData.zenGarden.snail };
|
||||
if (uData.zenGarden.lastVisit) this.lastVisit = uData.zenGarden.lastVisit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveToAuth() {
|
||||
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
|
||||
window.PvZAuth.saveUserData({
|
||||
zenGarden: {
|
||||
pots: this.pots,
|
||||
fertilizerCount: this.fertilizerCount,
|
||||
chocolateCount: this.chocolateCount,
|
||||
snail: this.snail,
|
||||
lastVisit: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
calculateOfflineEarnings() {
|
||||
const now = Date.now();
|
||||
const elapsedSec = Math.max(0, (now - this.lastVisit) / 1000);
|
||||
this.lastVisit = now;
|
||||
|
||||
// Count mature/blooming plants
|
||||
let earningRatePerMinute = 0;
|
||||
for (const pot of this.pots) {
|
||||
if (pot.plantType && pot.stage >= 2) {
|
||||
earningRatePerMinute += 15; // 15 coins / minute per mature plant
|
||||
} else if (pot.plantType && pot.stage === 1) {
|
||||
earningRatePerMinute += 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (earningRatePerMinute > 0 && elapsedSec > 15) {
|
||||
// Cap offline earnings to 8 hours max
|
||||
const cappedMinutes = Math.min(elapsedSec / 60, 480);
|
||||
const earned = Math.floor(cappedMinutes * earningRatePerMinute);
|
||||
if (earned > 0) {
|
||||
this.offlineEarned = earned;
|
||||
if (this.economy) {
|
||||
this.economy.addCoins(earned);
|
||||
}
|
||||
this.saveToAuth();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plant a new sprout in first empty pot
|
||||
plantSprout(plantType = 'sunflower') {
|
||||
const emptyPot = this.pots.find(p => !p.plantType);
|
||||
if (emptyPot) {
|
||||
emptyPot.plantType = plantType;
|
||||
emptyPot.stage = 0; // Sprout
|
||||
emptyPot.thirstTimer = 10;
|
||||
emptyPot.needsFertilizer = false;
|
||||
emptyPot.happyTimer = 30;
|
||||
emptyPot.coinTimer = 0;
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
waterPlant(potId) {
|
||||
const pot = this.pots.find(p => p.id === potId);
|
||||
if (!pot || !pot.plantType) return false;
|
||||
|
||||
if (pot.thirstTimer > 0) {
|
||||
pot.thirstTimer = 0;
|
||||
if (pot.stage < 2 && Math.random() < 0.6) {
|
||||
pot.needsFertilizer = true;
|
||||
}
|
||||
pot.happyTimer += 60;
|
||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||
this.spawnGardenCoin(potId, 'silver');
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fertilizePlant(potId) {
|
||||
const pot = this.pots.find(p => p.id === potId);
|
||||
if (!pot || !pot.plantType) return false;
|
||||
|
||||
if (pot.needsFertilizer && this.fertilizerCount > 0) {
|
||||
this.fertilizerCount--;
|
||||
pot.needsFertilizer = false;
|
||||
if (pot.stage < 2) {
|
||||
pot.stage++;
|
||||
if (pot.stage === 2 && this.economy && this.economy.engine && this.economy.engine.achievements) {
|
||||
this.economy.engine.achievements.trigger('zen_blooming');
|
||||
}
|
||||
}
|
||||
pot.happyTimer += 120;
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
this.spawnGardenCoin(potId, 'gold');
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
playMusicToPlant(potId) {
|
||||
const pot = this.pots.find(p => p.id === potId);
|
||||
if (!pot || !pot.plantType) return false;
|
||||
|
||||
pot.happyTimer = Math.max(pot.happyTimer, 180);
|
||||
this.spawnGardenCoin(potId, 'silver');
|
||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
|
||||
feedSnail() {
|
||||
if (this.chocolateCount > 0) {
|
||||
this.chocolateCount--;
|
||||
this.snail.isAwake = true;
|
||||
this.snail.sleepTimer = 300; // 5 min
|
||||
this.snail.boostTimer = 120; // 2 min super fast speed
|
||||
if (this.economy && this.economy.engine && this.economy.engine.achievements) {
|
||||
this.economy.engine.achievements.trigger('snail_feed');
|
||||
}
|
||||
if (window.PvZAudio) window.PvZAudio.playWin();
|
||||
this.saveToAuth();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
spawnGardenCoin(potId, coinType = 'silver') {
|
||||
const pot = this.pots.find(p => p.id === potId);
|
||||
if (!pot) return;
|
||||
|
||||
const potCol = potId % 4;
|
||||
const potRow = Math.floor(potId / 4);
|
||||
const baseX = 120 + potCol * 150;
|
||||
const baseY = 95 + potRow * 125;
|
||||
|
||||
this.gardenCoins.push({
|
||||
x: baseX + (Math.random() * 30 - 15),
|
||||
y: baseY + 30,
|
||||
vy: -140,
|
||||
type: coinType,
|
||||
value: coinType === 'diamond' ? 200 : (coinType === 'gold' ? 50 : 10),
|
||||
collected: false,
|
||||
alpha: 1.0,
|
||||
anim: 0
|
||||
});
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
// 1. Plant timers
|
||||
for (const pot of this.pots) {
|
||||
if (!pot.plantType) continue;
|
||||
|
||||
if (pot.happyTimer > 0) pot.happyTimer -= dt;
|
||||
|
||||
// Mature plants drop coins always (slower interval when unhappy)
|
||||
if (pot.stage >= 2) {
|
||||
pot.coinTimer -= dt;
|
||||
if (pot.coinTimer <= 0) {
|
||||
const happy = pot.happyTimer > 0;
|
||||
pot.coinTimer = happy ? (15 + Math.random() * 10) : (40 + Math.random() * 20);
|
||||
const isGold = happy && Math.random() < 0.4;
|
||||
this.spawnGardenCoin(pot.id, isGold ? 'gold' : 'silver');
|
||||
}
|
||||
}
|
||||
|
||||
// Thirst accumulation
|
||||
if (pot.thirstTimer <= 0 && Math.random() < 0.005) {
|
||||
pot.thirstTimer = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Snail update
|
||||
if (this.snail.isAwake) {
|
||||
this.snail.sleepTimer -= dt;
|
||||
if (this.snail.boostTimer > 0) this.snail.boostTimer -= dt;
|
||||
|
||||
if (this.snail.sleepTimer <= 0) {
|
||||
this.snail.isAwake = false;
|
||||
} else {
|
||||
const speed = this.snail.boostTimer > 0 ? 110 : 45;
|
||||
|
||||
// Move towards nearest uncollected coin
|
||||
let targetCoin = null;
|
||||
let minDist = Infinity;
|
||||
for (const c of this.gardenCoins) {
|
||||
if (!c.collected) {
|
||||
const d = Math.abs(c.x - this.snail.x);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
targetCoin = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetCoin) {
|
||||
this.snail.targetX = targetCoin.x;
|
||||
} else if (Math.abs(this.snail.x - this.snail.targetX) < 15) {
|
||||
// Pick a new random target at least 120px away to avoid spinning in place
|
||||
let newTarget;
|
||||
let tries = 0;
|
||||
do {
|
||||
newTarget = 80 + Math.random() * 560;
|
||||
tries++;
|
||||
} while (Math.abs(newTarget - this.snail.x) < 120 && tries < 10);
|
||||
this.snail.targetX = newTarget;
|
||||
}
|
||||
|
||||
const distToTarget = this.snail.targetX - this.snail.x;
|
||||
const moveStep = speed * dt;
|
||||
|
||||
if (Math.abs(distToTarget) <= moveStep) {
|
||||
this.snail.x = this.snail.targetX;
|
||||
} else if (distToTarget > 0) {
|
||||
this.snail.dir = 1;
|
||||
this.snail.x += moveStep;
|
||||
} else {
|
||||
this.snail.dir = -1;
|
||||
this.snail.x -= moveStep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Garden coins update & Snail collision
|
||||
const FLOOR_Y = 280; // garden floor where coins land and snail walks
|
||||
for (let i = this.gardenCoins.length - 1; i >= 0; i--) {
|
||||
const c = this.gardenCoins[i];
|
||||
c.anim += dt;
|
||||
|
||||
if (!c.collected) {
|
||||
// Coin physics: arc up then fall to garden floor
|
||||
if (c.y < FLOOR_Y) {
|
||||
c.y += c.vy * dt;
|
||||
c.vy += 350 * dt;
|
||||
if (c.y >= FLOOR_Y) {
|
||||
c.y = FLOOR_Y;
|
||||
c.vy = 0;
|
||||
}
|
||||
} else {
|
||||
c.vy = 0; // already on floor
|
||||
}
|
||||
|
||||
// Snail collects coins on the floor
|
||||
if (this.snail.isAwake && Math.abs(this.snail.x - c.x) < 45 && c.y >= FLOOR_Y - 40) {
|
||||
this.collectCoin(c);
|
||||
}
|
||||
} else {
|
||||
c.y -= 90 * dt;
|
||||
c.alpha -= 2.2 * dt;
|
||||
if (c.alpha <= 0) {
|
||||
this.gardenCoins.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectCoin(c) {
|
||||
if (c.collected) return;
|
||||
c.collected = true;
|
||||
if (this.economy) {
|
||||
this.economy.addCoins(c.value);
|
||||
}
|
||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||
this.saveToAuth();
|
||||
}
|
||||
|
||||
// Draw Zen Garden Canvas (720x330)
|
||||
draw(ctx) {
|
||||
// 1. Greenhouse Background
|
||||
const bgGrad = ctx.createLinearGradient(0, 0, 0, 330);
|
||||
bgGrad.addColorStop(0, '#1b3b22');
|
||||
bgGrad.addColorStop(0.6, '#2e5b38');
|
||||
bgGrad.addColorStop(1, '#3e2723');
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, 720, 330);
|
||||
|
||||
// Glass arches
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
||||
ctx.lineWidth = 2;
|
||||
for (let x = 60; x < 720; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, 260);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Wooden Shelves
|
||||
ctx.fillStyle = '#4e342e';
|
||||
ctx.fillRect(40, 150, 640, 16);
|
||||
ctx.fillRect(40, 275, 640, 16);
|
||||
|
||||
ctx.fillStyle = '#6d4c41';
|
||||
ctx.fillRect(40, 150, 640, 4);
|
||||
ctx.fillRect(40, 275, 640, 4);
|
||||
|
||||
// 2. Pots & Plants
|
||||
for (const pot of this.pots) {
|
||||
const col = pot.id % 4;
|
||||
const row = Math.floor(pot.id / 4);
|
||||
const px = 120 + col * 150;
|
||||
const py = (row === 0 ? 150 : 275);
|
||||
|
||||
this.drawPot(ctx, px, py, pot);
|
||||
}
|
||||
|
||||
// 3. Stinky the Snail
|
||||
this.drawSnail(ctx, this.snail.x, 290, this.snail);
|
||||
|
||||
// 4. Floating Coins
|
||||
for (const c of this.gardenCoins) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, c.alpha);
|
||||
ctx.translate(c.x, c.y);
|
||||
|
||||
ctx.fillStyle = c.type === 'diamond' ? '#00e5ff' : (c.type === 'gold' ? '#ffd700' : '#cfd8dc');
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#3e2723';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(c.type === 'diamond' ? '💎' : '$', 0, 1);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawPot(ctx, x, y, pot) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
// Ceramic clay pot
|
||||
ctx.fillStyle = '#bf360c';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-24, 0);
|
||||
ctx.lineTo(24, 0);
|
||||
ctx.lineTo(18, 22);
|
||||
ctx.lineTo(-18, 22);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Pot rim
|
||||
ctx.fillStyle = '#d84315';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-28, -6, 56, 7, 3);
|
||||
ctx.fill();
|
||||
|
||||
// If pot has plant
|
||||
if (pot.plantType) {
|
||||
if (pot.stage === 0) {
|
||||
// Sprout
|
||||
ctx.fillStyle = '#8bc34a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -12, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#558b2f';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -6);
|
||||
ctx.lineTo(0, -12);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Plant icon representation
|
||||
ctx.font = pot.stage === 1 ? '22px sans-serif' : '30px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
const icons = {
|
||||
sunflower: '🌻',
|
||||
peashooter: '🌱',
|
||||
wallnut: '🌰',
|
||||
snowpea: '❄️',
|
||||
repeater: '🌿',
|
||||
chomper: '🌺',
|
||||
potatomine: '🥔'
|
||||
};
|
||||
ctx.fillText(icons[pot.plantType] || '🌱', 0, -4);
|
||||
}
|
||||
|
||||
// Needs bubble indicator
|
||||
if (pot.thirstTimer > 0) {
|
||||
ctx.fillStyle = 'rgba(33, 150, 243, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(20, -32, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText('💧', 20, -28);
|
||||
} else if (pot.needsFertilizer) {
|
||||
ctx.fillStyle = 'rgba(76, 175, 80, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(20, -32, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText('🧪', 20, -28);
|
||||
} else if (pot.happyTimer > 0 && pot.stage >= 2) {
|
||||
ctx.fillStyle = '#ffd54f';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText('✨', 18, -34);
|
||||
}
|
||||
} else {
|
||||
// Empty pot text
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('+ Росток', 0, -8);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawSnail(ctx, x, y, snail) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(snail.dir, 1);
|
||||
|
||||
// Shell
|
||||
ctx.fillStyle = snail.boostTimer > 0 ? '#ffb300' : '#8d6e63';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-6, -10, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#4e342e';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Body
|
||||
ctx.fillStyle = '#ffecb3';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(4, -4, 18, 7, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Eye stalks
|
||||
ctx.strokeStyle = '#ffecb3';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(14, -6);
|
||||
ctx.lineTo(18, -14);
|
||||
ctx.moveTo(10, -6);
|
||||
ctx.lineTo(13, -15);
|
||||
ctx.stroke();
|
||||
|
||||
// Eyes
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.beginPath();
|
||||
ctx.arc(18, -14, 1.5, 0, Math.PI * 2);
|
||||
ctx.arc(13, -15, 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Zzz if asleep
|
||||
if (!snail.isAwake) {
|
||||
ctx.fillStyle = '#90caf9';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.fillText('Zzz...', -10, -26);
|
||||
} else if (snail.boostTimer > 0) {
|
||||
ctx.fillStyle = '#ff3d00';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.fillText('⚡ 2x', -8, -26);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user