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:
2026-09-17 09:30:52 +00:00
commit 6457a8904e
50 changed files with 14486 additions and 0 deletions
+271
View File
@@ -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);
}
};
+232
View File
@@ -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);
}
};
+209
View File
@@ -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 });
}
};
})();
+377
View File
@@ -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();
}
};
+72
View File
@@ -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})`
};
}
};
})();
+310
View File
@@ -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();
}
}
};
+741
View File
@@ -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();
}
};
+208
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
};
})();
+428
View File
@@ -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();
}
};
+102
View File
@@ -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] || [];
}
};
+204
View File
@@ -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();
}
};
+168
View File
@@ -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);
}
};
+393
View File
@@ -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();
}
}
};
+350
View File
@@ -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();
}
}
};
+538
View File
@@ -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();
}
};