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
+2
View File
@@ -0,0 +1,2 @@
DOMAIN=your-domain.com
EMAIL=admin@your-domain.com
+33
View File
@@ -0,0 +1,33 @@
# Environment variables
.env
.env.local
# SSL Certificates & Private Keys
certbot/
*.pem
*.key
*.crt
# Databases & User Data
data/*.db
data/*.sqlite
data/*.sqlite3
# System & Server Internal Notes
NOTES.md
GEMINI.md
SESSION_CHECKPOINT.md
_backup_pre_pvz/
snap/
# Python & Node Caches
__pycache__/
*.py[cod]
*$py.class
node_modules/
.npm/
# OS and Logs
*.log
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
# Журнал баг-репортов и обратной связи от игроков
> Данный файл обновляется автоматически при поступлении новых баг-репортов из игры.
> При каждом обновлении и новой итерации разработки ассистент анализирует этот список.
**Всего репортов:** 12 | **Последнее обновление:** 2026-09-16 11:20:33
| ID | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс |
|:---:|:---:|:---|:---:|:---:|:---|:---|
| 12 | 2026-09-16 11:20 | Admin | 🔴 Баг | ✅ Исправлено | Боулинг не работает, баг как и у Рогалика | Исправлено в v2.5: устранен ReferenceError в zombie.draw, налажен спавн и рикошет шаров боулинга. |
| 11 | 2026-09-16 11:19 | Admin | 🔴 Баг | ✅ Исправлено | "Я Зомби" Аналогичная проблема как и в режиме Зомбосс | Исправлено в v2.5: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла. |
| 10 | 2026-09-16 11:19 | Admin | 🔴 Баг | ✅ Исправлено | Режим Зомбос не работает, не проходит начальная заставка... игра не начинается | Исправлено в v2.5: убран ранний return из WaveSystem.update, блокировавший отсчет заставки и запуск битвы с боссом. |
| 9 | 2026-09-16 11:18 | Admin | 🔴 Баг | ✅ Исправлено | Достижения не засчитываются, нужно их сохранять | Исправлено в v2.5: исправлено затирание данных в PvZAuth.saveUserData (теперь выполняется merge), добавлен fallback в localStorage для гостей, подключены триггеры nut_wall и ice_age. |
| 8 | 2026-09-16 11:18 | Admin | 🔴 Баг | ✅ Исправлено | Рогалик. Режим имеет такой же баг как и режим выживания. | Исправлено в v2.5: устранен вылет при отрисовке зомби; исправлена ссылка на allCards при драфте семян; корректная инициализация колоды этажа. |
| 7 | 2026-09-16 11:08 | Admin | 🔴 Баг | ✅ Исправлено | Дзен сад не работает, улитка бегает и начинает крутится на месте (зависает) растения ничего не дают, фича не работает | Исправлено в v2.5: (1) устранено бесконечное дрожание направления улитки за счет пошагового приближения; (2) сбор монет на полу; (3) инструмент 'лейка' активен по умолчанию; (4) фоновое созревание и начисление монет. |
| 6 | 2026-09-16 11:07 | Admin | 🔴 Баг | ✅ Исправлено | Не происходит смена режима, после выхода в главное меню, надо перезагружать страницу. Баг. | Исправлено в v2.5: переключение режимов в меню теперь синхронно обновляет currentMode, закрывает все фоновые модалы, сбрасывает колоды и музыку, инициализируя корректный уровень для выбранного режима. |
| 5 | 2026-09-16 11:07 | Admin | 🔴 Баг | ✅ Исправлено | В режиме выживание, после начальной заставки пропадает строка растений и ничего не происходит. Играть не возможно, критический баг. | Исправлено в v2.5: устранена ошибка ReferenceError: gameState is not defined в zombie.draw(ctx, gameState), вызывавшая падение цикла requestAnimationFrame при появлении зомби после заставки. |
| 4 | 2026-09-16 11:01 | AutoTester | 💡 Идея | ✅ Исправлено | Проверка отправки баг-репорта через тестовый пайплайн | Тестовый пайплайн успешно проверен и функционирует штатно. |
| 3 | 2026-09-16 10:50 | ZombossHater | 🎨 UI | ✅ Исправлено | Элементы главного меню и кнопки вываливаются за границы экрана на небольших дисплеях. | Исправлено в v2.2: адаптивная панель навигации, компактные карточки и вертикальный скролл. |
| 2 | 2026-09-16 09:45 | DaveFan | 🔴 Баг | ✅ Исправлено | Режим 'Я, Зомби' не работал: сразу после старта выводилось сообщение 'победа'. | Исправлено в v1.6: устранено состояние гонки при инициализации газона. |
| 1 | 2026-09-16 09:30 | Player_Kopri | 🔴 Баг | ✅ Исправлено | Зомби после поедания растения не шел дальше, а оставался стоять на месте. | Исправлено в v1.6: сброшен флаг isEating и таймер поедания. |
+4
View File
@@ -0,0 +1,4 @@
FROM nginx:alpine
COPY game/ /usr/share/nginx/html/
COPY nginx/conf.d/ /etc/nginx/conf.d/
EXPOSE 80 443
+181
View File
@@ -0,0 +1,181 @@
# План разработки проекта: Plants vs. Zombies (ai.qpid.pro)
## 1. Цели и архитектурные принципы
- **Цель**: создать быстрый, легкий, полностью автономный браузерный клон Plants vs. Zombies, доступный на постоянном домене `https://ai.qpid.pro`, с максимальной глубиной удержания игроков (Retention), реиграбельностью, системой крафта, прокачки и пассивного прогресса.
- **Архитектурные принципы**:
1. *Zero External Runtime Dependencies*: отсутствие внешних CDN или тяжелых фреймворков.
2. *Single Static Origin*: чистый Nginx в Docker раздает сжатую статику, 100% исполнение на стороне клиента.
3. *Zero Asset Loading Delay*: процедурная графика Canvas и синтез звука через Web Audio API.
4. *Mobile & Desktop Hybrid*: идеальный отклик как для мыши, так и для сенсорных экранов.
5. *Autonomous Testability*: встроенный API `window.PvZTestAPI` и CLI-раннер `test_game.py` для непрерывного контроля качества.
6. *Глубокая система прогресса и крафта (RPG & Idle Core)*:
- **Сбор ресурсов в бою**: зомби дропают монеты и ремесленные материалы (Гумус, Металлолом, Солнечную эссенцию).
- **Садовая Мастерская**: отдельная уютная локация крафта и селекции растений.
- **3 Уровня прокачки каждого растения**: от базового саженца до плазменных и шипованных ультимейтов.
- **Глобальные перки садовника**: пассивные бонусы на всю игру (стартовое солнце, ускорение перезарядки, магнетизм).
---
## 2. Реализованные фазы разработки (v1.0 — v1.6)
### ✅ Фаза I (v1.1): «Мобильность, HiDPI и Музыка» (Выполнено)
- Тач-события (`touchstart`, `touchmove`, `touchend`) и свайп-сбор солнц.
- Поддержка `window.devicePixelRatio` для четкости на Retina/HiDPI.
- Полифоническая музыкальная тема *«Grasswalk»* на Web Audio API.
- Локальное сохранение очков и рекордов в `localStorage`.
### ✅ Фаза II (v1.2): «Тактический арсенал и новые зомби» (Выполнено)
- 4 новых растения: Картофельная мина, Повторитель, Зубастик, Халапеньо.
- 3 новых зомби: Зомби с шестом, Зомби с газетой, Зомби-футболист.
- Анимированная шкала прогресса волны в правом верхнем углу.
### ✅ Фаза III (v1.3): «Смена биомов (Ночь и Бассейн)» (Выполнено)
- Ночной газон: отсутствие падающего солнца, грибная экономика (Солнцегриб, Дымогриб), надгробия и Могилоед.
- Бассейн: 6 дорожек, 2 водных линии с кувшинками, зомби-уточки, очистители бассейна.
### ✅ Фаза IV (v1.4): «Кампания и Магазин Безумного Дэйва» (Выполнено)
- Кампания из уровней 1-1...1-10 с последовательной прогрессией.
- Выпадение серебряных, золотых и алмазных монет из зомби.
- Магазин Дэйва с покупкой +1 слота под семена и садовых грабель.
### ✅ Фаза V (v1.5): «Дополнительные режимы и мини-игры» (Выполнено)
- Режим «Выживание» (Endless Survival) с флагами.
- Мини-игра «Боулинг орехами» (Wall-nut Bowling) с рикошетом и конвейерной лентой.
- Режим «Я, Зомби» (I, Zombie).
### ✅ Фаза VI (v1.6): «Полировка UI, Меню, Авторизация/Гость и Test API» (Выполнено)
- **Исправление режима «Я, Зомби»**: синхронный старт без таймеров, 5 подсолнухов, +150 солнца за поедание, поражение при солнце < 50 и отсутствии зомби.
- **Устранение наложений UI**: верхний независимый Flexbox тулбар `.top-nav-bar` вне игрового холста.
- **Главное меню и перезапуск**: кнопка «🏠 Меню» на панели + кнопки «В МЕНЮ» на экранах победы и поражения.
- **Система авторизации и профилей**: окно входа/регистрации по логину и паролю + режим «Гость» (сессионная игра).
- **Автономное тестирование игры**: модуль `window.PvZTestAPI` и CLI-тест `scripts/test_game.py` (46 автотестов).
### ✅ Фаза VII (v1.7): «Сад-Мастерская: Крафт, 3 Уровня прокачки растений и Перки» (Выполнено)
- **Интерактивная локация «Сад-Мастерская»**: модальный экран с панелью материалов (🧪 биогумус, ⚙️ детали, ☀️ эссенция, 💎 кристаллы, 💰 монеты) и вкладками прокачки растений и перков.
- **3 Уровня прокачки растений (Tiers 1-3)**: Горохострел (криты + плазменный пирсинг), Подсолнух (ускорение + аура лечения), Стеноорех (поглощение урона + шипы отдачи), Картофельная мина (быстрый взвод 6с + ядерный Spudow 3x3), Повторитель, Ледяной горох, Зубастик.
- **5 Пассивных Перков Садовника**: Магнитный газон (автосбор), Стартовый капитал (+50/100/150 ☀️), Ускоренный полив (-10/20/30% CD), Упорные корни (+20/35/50% HP), Золотая рука (+30/60/100% дроп).
- **Анимированный лут**: подпрыгивание, свечение и притягивание магнитом.
- **Полное сохранение**: синхронизация материалов, уровней и перков с профилями в `PvZAuth`.
- **68/68 автоматических тестов** успешно проходят в `scripts/test_game.py`.
---
## 3. Детализация следующего Спринта v1.8: «Дзен-Сад (Пассивный доход и улитка Стинки) и Альманах»
### 3.1. Локация «Сад-Мастерская» (Garden Lab & Workshop)
* Переход на отдельный экран/модальное окно Сада из главного меню или верхней панели управления.
* Визуальное оформление: уютная теплица с деревянным верстаком, грядками и полками с колбами.
* 3 вкладки в интерфейсе:
1. **«Селекция растений»**: список всех растений игрока, текущий уровень (★, ★★, ★★★), стоимость улучшения в ресурсах.
2. **«Пассивные перки»**: ветки перков с индикатором прокачки.
3. **«Хранилище материалов»**: баланс собранных ресурсов.
### 3.2. Ремесленные материалы (Ресурсы боя)
| Ресурс | Иконка | Источник получения | Назначение |
|---|---|---|---|
| **Монеты** | 💰 | Зомби, газонокосилки, Дзен-сад | Базовые улучшения, магазин |
| **Био-гумус** | 🧪 | Обычные зомби, зомби с шестом, газетчики (шанс 25%) | Прокачка травяных растений |
| **Зомби-металл** | ⚙️ | Зомби с ведром, конусом, футболисты (шанс 40%) | Прокачка прочности, орехов, шипов |
| **Солнечная эссенция** | ☀️ | Сбор солнц на газоне (каждое 10-е солнце дает +1 эссенцию) | Прокачка подсолнухов и энергетиков |
| **Кристаллы льда/огня** | 💎 | Боссы, прохождение флагов в Выживании, редкие зомби | Ультимативные улучшения 3-го уровня |
### 3.3. Таблица 3 уровней прокачки растений (Plant Tiers)
Каждое растение получает 3 ступени мастерства:
1. **Горохострел (Peashooter)**:
- ★ **Ур. 1 (Базовый)**: Урон 20, интервал стрельбы 1.4 сек.
- ★★ **Ур. 2 («Скорострел»)**: Урон 25, интервал 1.15 сек, шанс критического удара 15% (x2 урон со вспышкой).
- ★★★ **Ур. 3 («Плазменный стрелок»)**: Плазменный энергетический заряд пробивает первого зомби насквозь и ранит следующего на 50% урона.
2. **Подсолнух (Sunflower)**:
- ★ **Ур. 1 (Базовый)**: 25 ☀️ каждые 12 сек.
- ★★ **Ур. 2 («Крупный подсолнух»)**: 35 ☀️ каждые 10 сек.
- ★★★ **Ур. 3 («Благодатный свет»)**: Каждая вспышка солнца излучает ауру, восстанавливающую +30 HP всем соседним растениям в радиусе 1 клетки.
3. **Стеноорех (Wall-nut)**:
- ★ **Ур. 1 (Базовый)**: 400 HP.
- ★★ **Ур. 2 («Каменный монолит»)**: 700 HP + поглощение 15% входящего урона.
- ★★★ **Ур. 3 («Шипованный бастион»)**: 1000 HP + возврат 30% полученного урона атакующим зомби при каждом укусе.
4. **Картофельная мина (Potato Mine)**:
- ★ **Ур. 1 (Базовый)**: Время взведения 15 сек, взрыв в клетке на 1800 урона.
- ★★ **Ур. 2 («Быстрый фитиль»)**: Время взведения сокращено до 6 сек!
- ★★★ **Ур. 3 («Атомный Spudow»)**: Взрыв 3x3 клетки + оставляет горящую яму лавы, замедляющую и сжигающую врагов.
5. **Повторитель (Repeater)**:
- ★: 2 горошины за выстрел.
- ★★: 2 горошины + шанс 20% выпустить третью горошину.
- ★★★: Огненный Повторитель — горошины зажигаются и наносят сплэш-урон.
6. **Ледяной горох (Snow Pea)**:
- ★: Замедление на 4 сек, урон 20.
- ★★: Увеличенный холод (замедление 6 сек, зона заморозки задевает зомби позади).
- ★★★: Шанс 15% полностью превратить зомби в ледяную глыбу на 2 сек.
7. **Зубастик (Chomper)**:
- ★: Заглатывание и 30 сек жевания.
- ★★: Жевание ускорено до 14 сек.
- ★★★: Способен проглотить сразу 2 зомби подряд перед началом жевания!
### 3.4. Глобальные Пассивные Перки Садовника
Постоянные пассивные улучшения, действующие во всех режимах игры:
* 🧲 **Магнитный газон (Ур. 1-3)**: Радиус автосбора монет и солнц увеличен на +30% / +60% / Полный автосбор по экрану.
* 💰 **Стартовый капитал (Ур. 1-3)**: Старт боя с +50 ☀️ / +100 ☀️ / +150 ☀️.
* ⏳ **Ускоренный полив (Ур. 1-3)**: Время перезарядки (cooldown) всех карточек семян снижено на -10% / -20% / -30%.
* 🛡️ **Упорные корни (Ур. 1-3)**: Запас здоровья всех растений на газоне увеличен на +15% / +30% / +50%.
* 🍀 **Золотая рука (Ур. 1-3)**: Шанс дропа монет и редких материалов из зомби повышен на +25% / +50% / +75%.
---
## 4. Завершённые фазы (v1.8 — v2.1)
### ✅ Фаза VIII (v1.8): «Дзен-Сад (Пассивный доход и улитка Стинки) и Альманах» (Выполнено)
- **Дзен-Сад с 8 горшками**: покупка саженцев (100 монет), 4 стадии роста, уход (лейка, удобрение, граммофон).
- **Улитка Стинки (Stinky the Snail)**: автосбор монет, кормление шоколадом для 3x ускорения.
- **Пассивный оффлайн-доход**: начисление монет при возвращении в игру.
- **Пригородный Альманах**: полное иллюстрированное досье на 13 растений и 8 зомби, включая секретного Зомби-Йети с дропом 4 алмазов.
### ✅ Фаза IX (v1.9): «Садовый Рогалик (Roguelike Drafting) и Система Достижений» (Выполнено)
- **Режим Рогалика**: этажи, драфт семян и 6 уникальных реликвий (Цепная молния, Токсичная почва, Солнечный резонанс, Железная кора, Вампирский сок, Древесный стимулятор).
- **HUD активных реликвий**: индикация на экране во время боя.
- **Система достижений**: 14+ трофеев с прогресс-барами, всплывающими тост-уведомлениями, наградами в монетах/кристаллах и сохранением в профиль.
### ✅ Фаза X (v2.0): «Туман, Крыша, Древо Мудрости, Доктор Зомбосс и Лидерборды» (Выполнено)
- **Биомы Туман и Крыша**:
- Крыша: скошенная черепица, кирпичная труба, необходимость Цветочных Горшков (`flowerpot`).
- Туман: плотная атмосферная пелена над правой частью газона с динамическим дрейфом.
- Навесная артиллерия: Капустопульта (`cabbagepult`) и Арбузопульта (`melonpult`) с параболической физикой и сплэш-уроном.
- **Древо Мудрости и Чит-коды**:
- Рост за древесное удобрение (150 монет) с мудрыми советами садовника.
- Секретные клавиатурные чит-коды: `mustache` (усы), `future` (неоновые киберочки), `daisies` (цветы при смерти), `dance` (танцы нежити), `pinata`.
- **Финальный босс Доктор Зомбосс & Зомбот**:
- 6000 HP, 3 фазы боя.
- Раздавливание растений лапой робота, десант зомби на резинках (Bungee), ледяные и огненные шары (растопление Халапеньо / заморозка Ледяным горохом), бросок фургона на 3-й фазе.
- Эпический салют взрывов и богатый дроп при победе.
- **Доска Почёта и Рекорды**:
- Топ-10 результатов по режимам: Выживание, Рогалик, Приключение, Я Зомби, Боулинг, Зомбосс.
### ✅ Фаза XI (v2.1 Финал): «Саундтрек, Boss Rush, Кастомные темы и PWA» (Выполнено)
- **Мульти-биомовый процедурный саундтрек**:
- Web Audio синтезатор генерирует уникальные музыкальные темы для каждого биома:
- `day` (солнечный весёлый Grasswalk),
- `night` (таинственный эфирный минор Moonglow),
- `pool` (карибский баунс / маримба Watery Lounge),
- `fog` (напряжённый эмбиент с детьюнированными пэдами),
- `roof` (героический марш с восходящими аккордами),
- `boss` (драйвовый тяжёлый рок/метал Brainiac Maniac).
- Методы `PvZAudio.startMusic(theme)`, `setTheme(theme)`, `getTheme()`.
- **Режим «Boss Rush» (Череда Боссов)**:
- 3 эскалирующие волны:
1. Орда тяжелоатлетов-футболистов и зомби с ведрами;
2. Элитный спецназ с шестами и газетами;
3. Доктор Зомбосс на боевом Зомботе.
- Награда за победу: +500 монет и +2 алмаза, собственный раздел в Доске Почёта.
- **Визуальные темы и кастомные скины**:
- Переключение в шапке игры в один клик:
- 🎨 **Классика** (традиционный газон и интерфейс),
- 🎨 **Киберпанк** (неоновый циан и маджента, матричные сетки),
- 🎨 **Ретро 8-бит** (пиксельный аркадный стиль CRT),
- 🎨 **Зима** (морозный лазурный газон, снежные акценты).
- Сохранение выбранной темы в аккаунт игрока.
- **PWA (Progressive Web App) и Офлайн-игра**:
- `manifest.json` со всеми размерами иконок и standalone-режимом.
- `sw.js` Service Worker с кэшированием всех файлов игры для мгновенного запуска без интернета.
- Сгенерированные иконки 192x192, 512x512 и favicon.ico.
- **Тестовый охват**:
- **172/172 автотестов** проходят со 100% успехом в `scripts/test_game.py` и `window.PvZTestAPI`.
- **Домен**: `https://ai.qpid.pro/` — полностью развернуто, работает без внешних зависимостей.
+84
View File
@@ -0,0 +1,84 @@
# 🌻 Plants vs. Zombies — Web Edition
Полноценная браузерная реализация культовой игры **Plants vs. Zombies** на чистом HTML5 Canvas и Web Audio API (0 внешних JS-фреймворков и библиотек) с серверной частью на Python и SQLite.
---
## 🎮 Особенности игры
- **5 Биомов газона:**
- ☀️ **День** (Day) — классический газон с падающим солнцем.
- 🌙 **Ночь** (Night) — отсутствие солнца с неба, грибы и могилы.
- 🏊 **Бассейн** (Pool) — 6 линий, водные дорожки, кувшинки.
- 🌫️ **Туман** (Fog) — ночной бассейн со сложной видимостью.
- 🏠 **Крыша** (Roof) — наклонные скаты, цветочные горшки и катапульты.
- **Режимы игры:**
- 🌿 **Кампания (Приключение):** 25 уровней с прогрессией и наградами.
- ⚔️ **Выживание (Survival):** бесконечные флаги с нарастающей сложностью.
- 🎳 **Боулинг (Wall-nut Bowling):** мини-игра с конвейерной подачей орехов.
- 🧠 **Я, Зомби (I, Zombie):** игра за сторону зомби против обороны растений.
- 🎲 **Садовый Рогалик (Roguelike):** драфт реликвий, случайные этажи и баффы.
- 👑 **Битва с Боссом & Boss Rush:** доктор Зомбосс на боевом роботе Зомботе.
- **Пассивные механики и прокачка:**
- 🧪 **Сад-Мастерская:** 3-уровневая прокачка растений и 5 пассивных перков.
- 🪴 **Дзен-Сад:** выращивание ростков, сбор монет и улитка Стинки.
- 📖 **Пригородный Альманах:** справочник всех 13 растений и 8 зомби.
- 🏆 **Зал Достижений:** система трофеев и наград.
- 🌳 **Древо Мудрости:** прокачка высоты и открытие скрытых чит-кодов.
- 🎨 **Темы оформления:** Классическая, Киберпанк, Ретро 8-bit, Зима.
- **Серверная часть и профили:**
- 👤 **Система аккаунтов и гостевой режим** (SQLite + REST API).
- 💾 **Автоматическая синхронизация сохранений** и прогресса на сервере.
- 🐛 **Интегрированная система баг-репортов** и обратной связи.
- 📱 **PWA:** поддержка автономной игры (Offline Service Worker).
---
## 📁 Структура проекта
```text
├── game/ # Frontend игры
│ ├── index.html # Главный HTML-документ
│ ├── css/style.css # Стили и адаптивный UI
│ ├── js/
│ │ ├── main.js # Точка входа
│ │ ├── game.js # Игровой движок (GameEngine)
│ │ ├── entities/ # Сущности (растения, зомби, снаряды)
│ │ ├── systems/ # Игровые системы (экономика, волны, комбат, сад и т.д.)
│ │ └── utils/ # Аудио, тест-API, спрайты
│ └── manifest.json # PWA манифест
├── scripts/ # Backend & скрипты
│ ├── feedback_server.py # HTTP REST сервер (Auth & Bug Reports)
│ ├── db.py # Менеджер SQLite базы данных
│ └── test_game.py # Автономный тест-раннер (235+ тестов)
├── nginx/ # Конфигурации Nginx
├── docker-compose.yml # Развертывание Nginx в Docker
└── .env.example # Пример переменных окружения
```
---
## 🚀 Быстрый старт
### Требования
- Python 3.10+
- Nginx / Docker (опционально)
### Запуск бэкенда
```bash
python3 scripts/feedback_server.py
```
Сервер запустится на порту `8001` и автоматически создаст базу данных `data/pvz.db`.
### Запуск тестов
```bash
python3 scripts/test_game.py
```
---
## 📜 Лицензия
Open Source. Создано в рамках проекта Qpid Web Gaming.
+184
View File
@@ -0,0 +1,184 @@
# Plants vs. Zombies (ai.qpid.pro) — Backlog & To-Do List
## Текущий статус: v1.7 Released (Production)
- [x] Разведка окружения и настройка DNS (`ai.qpid.pro` -> `31.77.203.213`)
- [x] Docker + Nginx + Certbot контейнеризация
- [x] SSL Let's Encrypt автоматизация и скрипт обновления
- [x] Базовый игровой движок на HTML5 Canvas (5x9 сетка, дорожки)
- [x] Газонокосилки (по одной на каждую линию)
- [x] Экономика солнца (падающее солнце + подсолнухи)
- [x] Базовые растения: Подсолнух, Горохострел, Ледяной горохострел, Стеноорех, Вишневая бомба, Лопата
- [x] Базовые зомби: Обычный, Зомби с конусом, Зомби с ведром, Зомби с флагом
- [x] Боевая система: регистрация урона, эффекты замедления, частицы взрывов
- [x] 4 волны с предупреждающими баннерами и экранами победы/поражения
- [x] Web Audio API синтезатор звуковых эффектов
---
## 🎯 Спринт v1.1: Мобильная адаптация, UX и Музыка (Released)
- [x] **Исправление багов**:
- [x] Зомби корректно продолжают движение после съедения растения (сброс флага `isEating` при гибели или отсутствии цели)
- [x] Peashooter стреляет только по живым целям (`!z.toRemove`)
- [x] **Сенсорное управление (Touch Controls)**
- [x] Тач-события `touchstart`, `touchmove`, `touchend` для смартфонов
- [x] Автоматический сбор солнц свайпом (проведение пальцем по экрану)
- [x] Увеличенные хитбоксы интерактивных элементов для мобильных пальцев
- [x] **Графика и рендеринг (HiDPI / Retina)**
- [x] Поддержка `window.devicePixelRatio` для четкости Canvas на смартфонах и 4K дисплеях
- [x] Адаптивное масштабирование `#game-container` через CSS `aspect-ratio` и `max-width: 98vw`
- [x] **Фоновое музыкальное сопровождение**
- [x] Синтез 8-битной полифонической темы *«Grasswalk»* на Web Audio API
- [x] Кнопка включения/выключения музыки `btn-music` на панели HUD
- [x] **Локальные сохранения рекордов**
- [x] Сохранение очков и лучшего рекорда в `localStorage`
- [x] Отображение рекорда на стартовом, победном и финальном экранах
---
## 🌻 Спринт v1.2: Тактический арсенал и Новые зомби (Released)
- [x] **Новые растения**
- [x] Картофельная мина (Potato Mine) — 25 ☀️, время взведения 14 сек, взрыв Spudow!
- [x] Повторитель (Repeater) — 200 ☀️, двойной залп горохом
- [x] Зубастик (Chomper) — 150 ☀️, моментальное заглатывание зомби и жевание
- [x] Халапеньо (Jalapeno) — 125 ☀️, выжигание всей горизонтальной полосы огнем
- [x] **Новые зомби**
- [x] Зомби с шестом (Pole Vaulter) — быстрый бег, перепрыгивание через 1-е растение с шестом
- [x] Зомби с газетой (Newspaper Zombie) — газета поглощает урон, ярость при уничтожении газеты
- [x] Зомби-футболист (Football Zombie) — тяжелый шлем, высокая скорость
- [x] **Индикатор волны (HUD)**
- [x] Шкала волны с визуальным индикатором прогресса
---
## 🌙 Спринт v1.3: Новые биомы (Ночь и Бассейн) (Released)
- [x] **Ночной газон (Night Stage)**
- [x] Отключение падения солнца с неба
- [x] Грибная экономика: Солнцегриб (Sun-shroom) и Дымогриб (Fume-shroom с пробивающими спорами)
- [x] Надгробия на поле и растение Могилоед (Grave Buster)
- [x] Ночной темный визуальный фон газона и подъем зомби из могил
- [x] **Бассейн (Pool Stage)**
- [x] 6 дорожек (две центральные дорожки с анимированной водой)
- [x] Растение Кувшинка (Lily Pad) для посадки наземных юнитов на воду
- [x] Водные зомби на кругах (Ducky Tube Zombies)
- [x] Плавающие очистители бассейна (Pool Cleaners)
---
## 🛒 Спринт v1.4: Кампания, Прогрессия и Магазин Дэйва (Released)
- [x] **Карта уровней (1-1 ... 1-10)**
- [x] Поэтапная кампания с автоматическим переходом на новый уровень
- [x] Автоматическая смена биома: День (1-5) -> Ночь (6-8) -> Бассейн (9-10)
- [x] **Экономика монет и Магазин Безумного Дэйва**
- [x] Выпадение серебряных (10), золотых (50) и алмазных (200) монет из поверженных зомби
- [x] Интерфейс магазина: покупка 6-го слота под семена (500 💰), садовых грабель (150 💰)
- [x] Механика грабель: автоматическое уничтожение первого зомби на газоне
- [x] **Профили игроков**
- [x] Сохранение монет, апгрейдов магазина, разблокированных уровней и рекорда в `localStorage`
---
## 🏆 Спринт v1.5: Режимы игры и Мини-игры (Released)
- [x] **Бесконечный режим (Endless Survival)**
- [x] Бесконечные волны с постоянно возрастающей интенсивностью и флагами
- [x] Отслеживание числа отбитых флагов и рекорда
- [x] **Мини-игра: Боулинг орехами (Wall-nut Bowling)**
- [x] Конвейерная подача орехов и вишен бесплатно
- [x] Механика рикошета катящихся орехов по диагоналям при ударе о зомби
- [x] Переключатель режимов прямо на стартовом экране («Приключение», «Выживание», «Боулинг», «Я, Зомби»)
---
## 🚀 Спринт v1.6: Исправление «Я, Зомби», Навбар без наложений, Меню, Профили/Гость и Автономное Test API (Released)
- [x] **Исправление бага режима «Я, Зомби»**:
- [x] Устранение мгновенной ложной победы: синхронная расстановка обороны газона без асинхронных задержек `setTimeout`
- [x] Размещение 5 целевых подсолнухов в колонке 0 и защитной линии растений (Горохострелы, Повторители, Стеноорехи, Мины)
- [x] Начисление +150 солнца за каждый съеденный подсолнух со звуковым эффектом
- [x] Корректное условие поражения: солнце < 50 и отсутствие живых зомби на газоне при уцелевших подсолнухах
- [x] Отключение автоматических волн и газонокосилок в режиме зомби
- [x] Проход зомби до левого края газона считается захватом мозгов и не вызывает проигрыш
- [x] **Полная переработка интерфейса (Устранение наложений элементов)**:
- [x] Создание независимой верхней панели `.top-nav-bar` на Flexbox над игровым холстом
- [x] Исключение абсолютного позиционирования кнопок поверх игрового Canvas, шкалы волн и слотов семян
- [x] Равномерное размещение элементов: слева «🏠 Меню» и плашка профиля; справа кнопки скорости «⚡ 1x/2x», музыки «🎵 Вкл/Выкл» и звука «🔊 Вкл/Выкл»
- [x] **Главное меню и перезапуск**:
- [x] Кнопка «🏠 Меню» доступна в любой момент игры: ставит игру на паузу и открывает окно выбора режима, уровня и магазина
- [x] Кнопки «В МЕНЮ» на экранах поражения и победы для мгновенного перезапуска или смены сценария
- [x] **Система авторизации и профилей (Логин/Пароль + Гость)**:
- [x] Реализована система `PvZAuth` в `js/systems/auth.js`
- [x] Модальное окно авторизации игрока: вход / быстрая регистрация по логину и паролю
- [x] Режим «Гость»: полноценная сессионная игра без сохранения прогресса на диск
- [x] Изоляция данных пользователя: сохраненные монеты, открытые уровни и покупки магазина привязаны к аккаунту
- [x] Индикатор текущего статуса игрока в верхнем тулбаре
- [x] **Автономное тестирование игры (Test API & CLI Runner)**:
- [x] Встроенный движковый API `window.PvZTestAPI` в `js/utils/test_api.js` (симуляция тиков, спавн сущностей, проверка боя, мины, косилок, победы в «Я, Зомби» и авторизации)
- [x] Автономный CLI скрипт `scripts/test_game.py` с 46 автоматическими проверками HTTP/2, статики, синтаксиса и бизнес-логики
---
## 🛠️ Спринт v1.7: Локация «Садовая Мастерская» — Крафт, 3 Уровня прокачки растений и Пассивные перки (Released)
- [x] **Новая игровая локация «Сад-Мастерская» (Garden Workshop & Lab)**:
- [x] Отдельный интерактивный экран/модальное окно Сада с верстаком прокачки и деревом перков
- [x] Переход в Сад одной кнопкой из верхнего меню и стартового окна («🧪 Сад-Мастерская»)
- [x] **Система ремесленных материалов (Drop & Craft)**:
- [x] Сбор ресурсов в процессе боя во всех режимах игры:
- 🧪 **Био-гумус (Bio-Humus)** — выпадает из поверженных базовых зомби
- ⚙️ **Зомби-детали (Scrap Metal)** — выпадает из бронированных врагов (ведра, конусы, футболисты)
- ☀️ **Солнечная эссенция (Sun Essence)** — накапливается за активный сбор солнечных капель
- 💎 **Алмазные кристаллы (Crystals)** — редкий дроп из волн флагов и элиты
- [x] Визуальные летающие дропы материалов с физикой падения, анимацией и подсветкой
- [x] Сбор материалов кликом мыши или автосбором через перк магнетизма
- [x] **Система 3 уровней прокачки каждого растения (Tier 1 ➔ Tier 2 ➔ Tier 3)**:
- [x] Индивидуальный прогресс каждого растения за монеты + материалы:
- **Горохострел**:
- *Ур. 1*: Базовый горох (урон 20)
- *Ур. 2 («Скорострел»)*: Урон 25, скорость атаки +20%, шанс крита 15% (x2 урон)
- *Ур. 3 («Плазменный стрелок»)*: Плазменный шар пробивает первого зомби насквозь и ранит идущего сзади
- **Подсолнух**:
- *Ур. 1*: Базовое солнце (25 ☀️)
- *Ур. 2 («Крупный подсолнух»)*: Дает 35 ☀️, ускоренное появление
- *Ур. 3 («Благодатный свет»)*: Каждая генерация солнца лечит +30 HP соседним растениям
- **Стеноорех**:
- *Ур. 1*: 400 HP
- *Ур. 2 («Каменный монолит»)*: 700 HP + поглощение 15% урона
- *Ур. 3 («Шипованный бастион»)*: 1000 HP + возврат 30% урона атакующим зомби при каждом укусе
- **Картофельная мина**:
- *Ур. 1*: Взведение 15 сек, взрыв в 1 клетке
- *Ур. 2*: Быстрое взведение всего за 6 сек
- *Ур. 3 («Атомный Spudow»)*: Взрыв по площади 3x3 клетки + лавовая зона
- **Повторитель, Ледяной горох, Зубастик** — расширенные деревья прокачки до 3 уровней
- [x] Визуальное отображение уровней: звездочки (★★ / ★★★) над карточками в колоде и световые ауры на газоне
- [x] **Пассивные Перки Садовника (Глобальное дерево технологий)**:
- [x] 5 пассивных улучшений профиля:
- 🧲 *«Магнитный газон»* — увеличенный радиус автосбора монет, солнц и лута (до полного экрана на ур. 3)
- ☀️ *«Стартовый капитал»* — старт каждого боя с +50/+100/+150 ☀️
- ⏳ *«Ускоренный полив»* — сокращение кулдауна семян на -10%/-20%/-30%
- 🛡️ *«Упорные корни»* — запас здоровья всех посадок +20%/+35%/+50%
- 🍀 *«Золотая рука»* — шанс дропа золотых монет и редких материалов повышен на +30%/+60%/+100%
- [x] Сохранение уровней растений, ресурсов и купленных перков в профиле игрока (`PvZAuth`)
---
## 🪴 Спринт v1.8: Дзен-Сад (Пассивный доход и улитка Стинки) и Альманах (Запланировано)
- [ ] **Дзен-Сад (Zen Garden)**:
- [ ] Выращивание растений в горшках (лейка, удобрения, граммофон)
- [ ] Пассивный доход монет каждые несколько часов (начисление монет за время отсутствия)
- [ ] Улитка Стинки (Stinky the Snail) пылесосит монеты при подкармливании шоколадом
- [ ] **Альманах пригородных растений и зомби (Suburban Almanac)**:
- [ ] Энциклопедия с описанием, статистикой и лором всех персонажей
- [ ] Секретный Зомби-Йети (дроп 4 алмазов)
---
## 🎲 Спринт v1.9: «Садовый Рогалик» и Достижения (Запланировано)
- [ ] **Режим «Садовый Рогалик» (Garden Roguelike)**:
- [ ] Драфт семян и выбор 1 из 3 мощных реликвий после каждой волны (молнии, яд, взрывы)
- [ ] **Система Достижений (30+ ачивок с наградами)**:
- [ ] Зал трофеев в профиле игрока
---
## 👑 Спринт v2.0: Туман, Крыша, Босс Зомбосс и Лидерборды (Запланировано)
- [ ] **Биомы Туман и Крыша** с катапультами
- [ ] **Дерево Мудрости** и клавиатурные чит-коды (`mustache`, `future`, `dance`, `pinata`)
- [ ] **Финальный босс Доктор Зомбосс** на гигантском Zombot
- [ ] **Глобальные таблицы лидеров**
+20
View File
@@ -0,0 +1,20 @@
services:
nginx:
build: .
container_name: pvz_nginx
restart: unless-stopped
network_mode: host
volumes:
- ./game:/usr/share/nginx/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
environment:
- DOMAIN=${DOMAIN:-ai.qpid.pro}
certbot:
image: certbot/certbot:latest
container_name: pvz_certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
+1894
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+463
View File
@@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plants vs. Zombies — Игра онлайн на ai.qpid.pro</title>
<meta name="description" content="Полный клон Plants vs. Zombies на Canvas: день, ночь, бассейн, туман, крыша, боулинг, магазин Дэйва, Зомбосс.">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<meta name="theme-color" content="#2e7d32">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="PvZ Web">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="game-wrapper">
<!-- Clean Flexbox Top Navigation Bar (No collisions) -->
<header class="top-nav-bar">
<div class="nav-left">
<button id="btn-menu" class="nav-btn" title="Выйти в главное меню">🏠 Меню</button>
<button id="btn-campaign-open" class="nav-btn" title="Карта Кампании: 25 уровней и 5 биомов">🗺️ Кампания</button>
<button id="btn-workshop" class="nav-btn" title="Сад-Мастерская: прокачка растений и перки">🧪 Мастерская</button>
<button id="btn-zengarden" class="nav-btn" title="Дзен-Сад: пассивный доход и улитка Стинки">🪴 Дзен-Сад</button>
<button id="btn-almanac" class="nav-btn" title="Пригородный Альманах растений и зомби">📖 Альманах</button>
<button id="btn-achievements" class="nav-btn" title="Зал Достижений и Трофеев">🏆 Достижения</button>
<button id="btn-tree" class="nav-btn" title="Древо Мудрости и Секретные Чит-коды">🌳 Древо</button>
<button id="btn-leaderboard" class="nav-btn" title="Доска Почёта и Рекорды">🏅 Рекорды</button>
<button id="btn-bugreport" class="nav-btn btn-bug-nav" title="Чат баг-репортов и предложений">🐛 Баги</button>
<div id="user-profile-badge" class="profile-badge">
<span id="badge-user-name">👤 Гость</span>
<button id="btn-auth-open" class="btn-mini" title="Управление профилем">Войти</button>
<button id="btn-auth-logout-nav" class="btn-mini btn-logout-mini hidden" title="Выйти из профиля в гостевой режим">Выйти</button>
</div>
</div>
<div class="nav-right">
<button id="btn-theme" class="nav-btn" title="Смена темы оформления (Классика, Киберпанк, Ретро, Зима)">🎨 Классика</button>
<button id="btn-speed" class="nav-btn" title="Ускорить игру">⚡ 1x</button>
<button id="btn-music" class="nav-btn" title="Включить/выключить музыку">🎵 Музыка</button>
<button id="btn-mute" class="nav-btn" title="Включить/выключить звук">🔊 Звук</button>
</div>
</header>
<div id="game-container">
<!-- Main Game Canvas -->
<canvas id="gameCanvas" width="900" height="600"></canvas>
<!-- Start Screen Overlay -->
<div id="start-modal" class="overlay">
<div class="banner-card start-card">
<div class="start-header">
<h1>PLANTS vs. ZOMBIES</h1>
<p>Защитите газон от нашествия зомби! Выберите режим игры и начинайте сражение.</p>
</div>
<!-- Mode Selectors -->
<div class="mode-selector">
<button id="btn-mode-adv" class="mode-btn active">🌿 Приключение</button>
<button id="btn-mode-surv" class="mode-btn">⚔️ Выживание</button>
<button id="btn-mode-bowl" class="mode-btn">🎳 Боулинг</button>
<button id="btn-mode-izombie" class="mode-btn">🧠 Я, Зомби</button>
<button id="btn-mode-rogue" class="mode-btn">🎲 Рогалик</button>
<button id="btn-mode-boss" class="mode-btn">👑 Зомбосс</button>
<button id="btn-mode-bossrush" class="mode-btn">⚡ Boss Rush</button>
</div>
<div id="start-record" class="start-record-line"></div>
<!-- Hero Action Row -->
<div class="start-hero-row">
<button id="btn-start" class="btn btn-hero">▶ ИГРАТЬ</button>
<button id="btn-campaign-select" class="btn btn-campaign btn-hero">🗺️ КАРТА КАМПАНИИ (25 УРОВНЕЙ)</button>
</div>
<!-- Secondary Activities Row -->
<div class="start-activities-grid">
<button id="btn-shop" class="btn btn-shop btn-act">🏪 Магазин Дэйва</button>
<button id="btn-workshop-start" class="btn btn-workshop btn-act">🧪 Сад-Мастерская</button>
<button id="btn-zengarden-start" class="btn btn-zengarden btn-act">🪴 Дзен-Сад</button>
<button id="btn-almanac-start" class="btn btn-almanac btn-act">📖 Альманах</button>
<button id="btn-achievements-start" class="btn btn-achievements btn-act">🏆 Достижения</button>
<button id="btn-tree-start" class="btn btn-tree btn-act">🌳 Древо Мудрости</button>
<button id="btn-leaderboard-start" class="btn btn-leaderboard btn-act">🏅 Рекорды</button>
<button id="btn-bugreport-start" class="btn btn-bugreport btn-act">🐛 Баг-репорт / Чат</button>
</div>
<div class="footer-info">ai.qpid.pro &bull; 13 растений &bull; 8 видов зомби &bull; 5 биомов: День, Ночь, Бассейн, Туман, Крыша &bull; 25 уровней</div>
</div>
</div>
<!-- Crazy Dave's Shop Modal -->
<div id="shop-modal" class="overlay hidden">
<div class="banner-card shop-card">
<h1 style="color: #ffa000;">МАГАЗИН БЕЗУМНОГО ДЭЙВА</h1>
<div id="shop-coins" style="font-size: 18px; color: #ffd54f; font-weight: bold; margin-bottom: 16px;">💰 Монеты: 0</div>
<div class="shop-items">
<div class="shop-item">
<div class="shop-item-info">
<h3>+1 Слот под семена</h3>
<p>Разблокирует 6-й слот для пакета растений в колоде.</p>
</div>
<button id="btn-buy-slot" class="btn-buy">Купить (500 💰)</button>
</div>
<div class="shop-item">
<div class="shop-item-info">
<h3>Садовые грабли</h3>
<p>Автоматически уничтожают первого зомби на газоне в каждом раунде.</p>
</div>
<button id="btn-buy-rake" class="btn-buy">Купить (150 💰)</button>
</div>
</div>
<button id="btn-close-shop" class="btn" style="margin-top: 18px;">НАЗАД</button>
</div>
</div>
<!-- Workshop & Laboratory Modal -->
<div id="workshop-modal" class="overlay hidden">
<div class="banner-card workshop-card">
<div class="workshop-header">
<h1 style="color: #69f0ae; font-size: 26px; margin: 0;">🧪 САД-МАСТЕРСКАЯ</h1>
<p style="font-size: 13px; color: #b0bec5; margin: 4px 0 10px 0;">
Крафтите постоянные улучшения растений (до 3★) и развивайте пассивные перки Садовода!
</p>
<!-- Materials Inventory Bar -->
<div id="workshop-materials" class="materials-bar">
<div class="mat-item" title="Биогумус (падает с базовых зомби)"><span class="mat-icon">🧪</span> <span id="mat-bio">0</span> биогумус</div>
<div class="mat-item" title="Детали и шестерни (падают с бронированных зомби)"><span class="mat-icon">⚙️</span> <span id="mat-scrap">0</span> детали</div>
<div class="mat-item" title="Солнечная эссенция (накапливается при сборе солнц)"><span class="mat-icon">☀️</span> <span id="mat-sun">0</span> эссенция</div>
<div class="mat-item" title="Кристаллы (падают с элитных зомби и флагоносцев)"><span class="mat-icon">💎</span> <span id="mat-crystal">0</span> кристаллы</div>
<div class="mat-item mat-coins" title="Монеты игрока"><span class="mat-icon">💰</span> <span id="mat-coins">0</span> монет</div>
</div>
<!-- Workshop Tabs -->
<div class="workshop-tabs">
<button id="tab-plants" class="ws-tab active">🌿 Прокачка Растений</button>
<button id="tab-perks" class="ws-tab">✨ Перки Садовода</button>
</div>
</div>
<!-- Tab 1: Plants Upgrade Grid -->
<div id="ws-content-plants" class="ws-content">
<div id="plants-upgrade-list" class="upgrade-grid"></div>
</div>
<!-- Tab 2: Passive Perks Tree -->
<div id="ws-content-perks" class="ws-content hidden">
<div id="perks-upgrade-list" class="upgrade-grid"></div>
</div>
<div style="margin-top: 12px; text-align: center;">
<button id="btn-close-workshop" class="btn" style="padding: 8px 32px;">НАЗАД</button>
</div>
</div>
</div>
<!-- Zen Garden Modal -->
<div id="zengarden-modal" class="overlay hidden">
<div class="banner-card zengarden-card">
<div class="zengarden-header">
<h1 style="color: #aed581; font-size: 24px; margin: 0;">🪴 ДЗЕН-САД</h1>
<p style="font-size: 13px; color: #dcedc8; margin: 3px 0 6px 0;">
Выращивайте растения в горшках, ухаживайте за саженцами и собирайте пассивный доход!
</p>
<div id="zen-status-bar" class="zen-tools-bar">
<button id="zen-tool-water" class="btn-zen-tool active" title="Полить жаждущее растение (клик по горшку)">💧 Лейка</button>
<button id="zen-tool-fert" class="btn-zen-tool" title="Подкормить удобрением для роста">🧪 Удобрение (<span id="zen-fert-count">5</span>)</button>
<button id="zen-tool-music" class="btn-zen-tool" title="Включить граммофон для хорошего настроения">🎵 Музыка</button>
<button id="zen-feed-snail" class="btn-zen-tool" title="Покормить улитку Стинки шоколадом для ускорения сбора монет">🍫 Шоколад (<span id="zen-choc-count">3</span>)</button>
<button id="zen-plant-sprout" class="btn-zen-tool" title="Посадить новый росток в свободный горшок">🌱 +Росток (100 💰)</button>
</div>
</div>
<div class="zen-canvas-wrap">
<canvas id="zenCanvas" width="720" height="330"></canvas>
</div>
<div class="zen-footer" style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px;">
<div id="zen-offline-alert" style="font-size: 13px; color: #ffd54f; font-weight: bold;"></div>
<button id="btn-close-zengarden" class="btn" style="padding: 6px 28px;">НАЗАД</button>
</div>
</div>
</div>
<!-- Suburban Almanac Modal -->
<div id="almanac-modal" class="overlay hidden">
<div class="banner-card almanac-card">
<div class="almanac-header">
<h1 style="color: #ffb74d; font-size: 24px; margin: 0;">📖 ПРИГОРОДНЫЙ АЛЬМАНАХ</h1>
<div class="almanac-tabs">
<button id="tab-almanac-plants" class="almanac-tab active">🌿 Растения</button>
<button id="tab-almanac-zombies" class="almanac-tab">🧟 Зомби</button>
</div>
</div>
<div class="almanac-body">
<!-- Left: Grid of thumbnails -->
<div id="almanac-list" class="almanac-grid"></div>
<!-- Right: Detailed card of selected item -->
<div id="almanac-details" class="almanac-detail-card"></div>
</div>
<div style="margin-top: 8px; text-align: center;">
<button id="btn-close-almanac" class="btn" style="padding: 6px 28px;">ЗАКРЫТЬ</button>
</div>
</div>
</div>
<!-- Achievements & Trophy Modal -->
<div id="achievements-modal" class="overlay hidden">
<div class="banner-card achievements-card">
<div class="achievements-header">
<h1 style="color: #ffd54f; font-size: 24px; margin: 0;">🏆 ЗАЛ ДОСТИЖЕНИЙ</h1>
<p style="font-size: 13px; color: #ffe082; margin: 3px 0 8px 0;">
Выполняйте испытания, открывайте награды и забирайте монеты и кристаллы!
</p>
</div>
<div id="achievements-list" class="achievements-grid"></div>
<div style="margin-top: 10px; text-align: center;">
<button id="btn-close-achievements" class="btn" style="padding: 6px 28px;">ЗАКРЫТЬ</button>
</div>
</div>
</div>
<!-- Roguelike Draft Choices Modal -->
<div id="draft-modal" class="overlay hidden">
<div class="banner-card draft-card">
<h1 style="color: #64ffda; font-size: 24px; margin: 0 0 6px 0;">🎲 ВЫБЕРИТЕ НАГРАДУ ЭТАПА</h1>
<p style="font-size: 13px; color: #b2dfdb; margin-bottom: 14px;">
Волна зачищена! Выберите новое семя в колоду или мощную реликвию:
</p>
<div id="draft-choices-list" class="draft-grid"></div>
</div>
</div>
<!-- Tree of Wisdom Modal -->
<div id="tree-modal" class="overlay hidden">
<div class="banner-card tree-card">
<div class="tree-header">
<h1 style="color: #81c784; font-size: 24px; margin: 0;">🌳 ДРЕВО МУДРОСТИ</h1>
<div id="tree-height-display" style="font-size: 14px; color: #ffeb3b; font-weight: bold; margin: 4px 0 10px 0;">
Высота: 0 футов
</div>
</div>
<!-- Tree Art Illustration & Speech Bubble -->
<div class="tree-display-box">
<div class="tree-visual-wrapper">
<div id="tree-art" class="tree-art-stem">🌳</div>
</div>
<div class="tree-wisdom-bubble">
<div id="tree-wisdom-title" class="tree-wisdom-title">Первый росток</div>
<div id="tree-wisdom-quote" class="tree-wisdom-quote">
«Корми меня древесным удобрением, и я открою тебе сокровенные тайны газона и секретные чит-коды игры!»
</div>
</div>
</div>
<!-- Cheats Console & Toggles -->
<div class="tree-cheats-section">
<div style="font-size: 13px; color: #ffd54f; font-weight: bold; margin-bottom: 6px;">
РАЗБЛОКИРОВАННЫЕ ЧИТ-КОДЫ:
</div>
<div id="tree-cheats-list" class="tree-cheats-grid"></div>
</div>
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 14px; flex-wrap: wrap;">
<button id="btn-tree-feed" class="btn" style="background: #4caf50;">Подкормить Древо (150 💰)</button>
<button id="btn-tree-close" class="btn btn-secondary">Закрыть</button>
</div>
</div>
</div>
<!-- Leaderboards Modal -->
<div id="leaderboard-modal" class="overlay hidden">
<div class="banner-card leaderboard-card">
<h1 style="color: #ffd54f; font-size: 24px; margin: 0 0 6px 0;">🏅 ДОСКА ПОЧЁТА И РЕКОРДЫ</h1>
<p style="font-size: 13px; color: #ffe082; margin-bottom: 12px;">
Лучшие результаты защитников газона по всем режимам!
</p>
<div class="leaderboard-tabs">
<button id="tab-lb-survival" class="lb-tab active">⚔️ Выживание</button>
<button id="tab-lb-rogue" class="lb-tab">🎲 Рогалик</button>
<button id="tab-lb-adv" class="lb-tab">🌿 Приключение</button>
<button id="tab-lb-izombie" class="lb-tab">🧠 Я, Зомби</button>
<button id="tab-lb-bowl" class="lb-tab">🎳 Боулинг</button>
<button id="tab-lb-boss" class="lb-tab">👑 Зомбосс</button>
<button id="tab-lb-bossrush" class="lb-tab">⚡ Boss Rush</button>
</div>
<div id="leaderboard-content" class="leaderboard-table-wrapper"></div>
<div style="margin-top: 12px; text-align: center;">
<button id="btn-leaderboard-close" class="btn" style="padding: 6px 26px;">ЗАКРЫТЬ</button>
</div>
</div>
</div>
<!-- Authentication & Profile Modal -->
<div id="auth-modal" class="overlay hidden">
<div class="banner-card auth-card">
<h1 style="color: #ffeb3b; font-size: 26px;">ПРОФИЛЬ ИГРОКА</h1>
<div id="auth-current-status" style="font-size: 14px; margin-bottom: 10px; color: #a5d6a7;">
Текущий режим: <strong>Гость</strong>
</div>
<p id="auth-modal-desc" style="font-size: 13px; margin-bottom: 14px; color: #d7ccc8;">
Войдите под логином и паролем для сохранения монет и прогресса, либо играйте как Гость (без сохранения).
</p>
<form id="auth-form" onsubmit="return false;" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px;">
<input type="text" id="auth-username" class="auth-input" placeholder="Имя пользователя (логин)" maxlength="20" autocomplete="username">
<input type="password" id="auth-password" class="auth-input" placeholder="Пароль" maxlength="30" autocomplete="current-password">
<div id="auth-error" class="auth-error hidden"></div>
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-top: 6px;">
<button type="button" id="btn-auth-submit" class="btn btn-auth-submit">Войти / Регистрация</button>
<button type="button" id="btn-auth-guest" class="btn btn-secondary">Играть как Гость</button>
<button type="button" id="btn-auth-logout" class="btn btn-danger hidden">🚪 Выйти из профиля</button>
</div>
</form>
<button id="btn-auth-close" class="btn btn-secondary" style="font-size: 14px; padding: 6px 22px;">Закрыть</button>
</div>
</div>
<!-- Game Over Overlay -->
<div id="game-over-modal" class="overlay hidden">
<div class="banner-card">
<h1 style="color: #f44336;">ЗОМБИ СЪЕЛИ ВАШИ МОЗГИ!</h1>
<p id="game-over-desc">Зомби прорвали оборону газона. Попробуйте снова!</p>
<div id="game-over-stats" style="font-size: 15px; color: #fff; margin-bottom: 16px;"></div>
<div style="display: flex; gap: 12px; justify-content: center;">
<button id="btn-restart-lose" class="btn">НАЧАТЬ ЗАНОВО</button>
<button id="btn-menu-lose" class="btn btn-secondary">В МЕНЮ</button>
</div>
</div>
</div>
<!-- Adventure Campaign Level Select Modal -->
<div id="campaign-modal" class="overlay hidden">
<div class="banner-card campaign-card">
<div class="campaign-header">
<h1 style="color: #81c784; font-size: 24px; margin: 0 0 4px 0;">🗺️ КАРТА КАМПАНИИ</h1>
<p style="font-size: 13px; color: #cfd8dc; margin: 0 0 10px 0;">
25 уникальных уровней в 5 мирах: День, Ночь, Бассейн, Туман и Крыша с роботом Зомбоссом!
</p>
<!-- World Tabs -->
<div class="campaign-tabs">
<button id="tab-world-1" class="campaign-tab active" data-world="1">☀️ 1. День</button>
<button id="tab-world-2" class="campaign-tab" data-world="2">🌙 2. Ночь</button>
<button id="tab-world-3" class="campaign-tab" data-world="3">🏊 3. Бассейн</button>
<button id="tab-world-4" class="campaign-tab" data-world="4">🌫️ 4. Туман</button>
<button id="tab-world-5" class="campaign-tab" data-world="5">🏠 5. Крыша</button>
</div>
</div>
<!-- Levels Grid for Active World -->
<div id="campaign-levels-grid" class="campaign-levels-grid"></div>
<div style="margin-top: 14px; text-align: center;">
<button id="btn-campaign-close" class="btn btn-secondary" style="padding: 8px 32px;">ЗАКРЫТЬ</button>
</div>
</div>
</div>
<!-- Bug Reporting & Community Feedback Modal -->
<div id="bugreport-modal" class="overlay hidden">
<div class="banner-card bugreport-card">
<div class="bug-header">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
<h1 style="color: #4fc3f7; font-size: 22px; margin: 0;">🐛 ЧАТ БАГ-РЕПОРТОВ И ОТЗЫВОВ</h1>
<span id="bug-count-badge" class="bug-count-badge">3 репорта</span>
</div>
<p style="font-size: 12px; color: #b0bec5; margin: 4px 0 8px 0;">
Нашли баг или хотите предложить улучшение? Напишите сюда — сообщения сохраняются на сервере и исправляются разработчиком!
</p>
<!-- Filter Tabs -->
<div class="bug-filter-tabs">
<button id="tab-bug-all" class="bug-tab active" data-filter="all">Все</button>
<button id="tab-bug-bugs" class="bug-tab" data-filter="bug">🔴 Баги</button>
<button id="tab-bug-ideas" class="bug-tab" data-filter="feature">💡 Идеи</button>
<button id="tab-bug-visual" class="bug-tab" data-filter="visual">🎨 Графика/UI</button>
<button id="tab-bug-fixed" class="bug-tab" data-filter="resolved">✅ Исправлено</button>
</div>
</div>
<!-- Bug Feed List -->
<div id="bugreport-feed" class="bugreport-feed"></div>
<!-- Submission Form -->
<form id="bugreport-form" class="bugreport-form" onsubmit="return false;">
<div class="bug-form-row">
<input type="text" id="bug-input-name" class="bug-input" placeholder="Ваше имя (или никнейм)" maxlength="30">
<select id="bug-input-category" class="bug-select">
<option value="bug">🔴 Баг / Ошибка</option>
<option value="feature">💡 Предложение / Идея</option>
<option value="visual">🎨 Графика / Интерфейс</option>
<option value="balance">⚖️ Баланс сложности</option>
</select>
</div>
<div class="bug-form-row" style="margin-top: 6px;">
<textarea id="bug-input-text" class="bug-textarea" placeholder="Опишите баг подробно: какой уровень, что произошло, как повторить..." maxlength="1000" rows="2"></textarea>
<button type="button" id="btn-bug-submit" class="btn-bug-send">Отправить 🚀</button>
</div>
<div id="bug-form-status" class="bug-status-msg hidden"></div>
</form>
<div style="margin-top: 8px; text-align: center;">
<button id="btn-bug-close" class="btn btn-secondary" style="padding: 6px 26px; font-size: 13px;">ЗАКРЫТЬ</button>
</div>
</div>
</div>
<!-- Victory Overlay -->
<div id="victory-modal" class="overlay hidden">
<div class="banner-card">
<h1 style="color: #4caf50;">ПОБЕДА!</h1>
<div id="victory-stats" style="font-size: 15px; color: #ffeb3b; margin-bottom: 16px;"></div>
<div style="display: flex; gap: 12px; justify-content: center;">
<button id="btn-restart-win" class="btn">СЛЕДУЮЩИЙ ЭТАП</button>
<button id="btn-menu-win" class="btn btn-secondary">В МЕНЮ</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts loaded in dependency order -->
<script src="js/utils/assets.js"></script>
<script src="js/utils/test_api.js"></script>
<script src="js/systems/auth.js"></script>
<script src="js/entities/projectile.js"></script>
<script src="js/entities/plant.js"></script>
<script src="js/entities/zombie.js"></script>
<script src="js/systems/grid.js"></script>
<script src="js/systems/economy.js"></script>
<script src="js/systems/combat.js"></script>
<script src="js/systems/workshop.js"></script>
<script src="js/systems/zengarden.js"></script>
<script src="js/systems/almanac.js"></script>
<script src="js/systems/achievements.js"></script>
<script src="js/systems/roguelike.js"></script>
<script src="js/systems/tree.js"></script>
<script src="js/systems/boss.js"></script>
<script src="js/systems/leaderboard.js"></script>
<script src="js/systems/campaign.js"></script>
<script src="js/systems/feedback.js"></script>
<script src="js/systems/wave.js"></script>
<script src="js/game.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+970
View File
@@ -0,0 +1,970 @@
// Plant Entities (Sunflower, Peashooter, SnowPea, Wallnut, CherryBomb, PotatoMine, Repeater, Chomper, Jalapeno, Sunshroom, Fumeshroom, Lilypad, Gravebuster)
window.Plant = class Plant {
constructor(type, col, row, gridX, gridY, tileSizeX, tileSizeY, level = 1) {
this.type = type;
this.col = col;
this.row = row;
this.x = gridX + tileSizeX / 2;
this.y = gridY + tileSizeY / 2;
this.width = tileSizeX;
this.height = tileSizeY;
this.level = level || 1;
this.timer = 0;
this.animTime = Math.random() * 10;
this.toRemove = false;
// Attributes by type & level
this.maxHealth = 300;
this.damage = 20;
this.armorReduction = 0;
this.recoilDps = 0;
switch (type) {
case 'sunflower':
this.sunInterval = this.level === 3 ? 9 : (this.level === 2 ? 10 : 13);
this.sunAmount = this.level >= 2 ? 35 : 25;
this.sunTimer = 3;
break;
case 'peashooter':
this.shootInterval = this.level === 3 ? 1.05 : (this.level === 2 ? 1.15 : 1.4);
this.damage = this.level === 3 ? 30 : (this.level === 2 ? 25 : 20);
this.critChance = this.level === 2 ? 0.15 : 0;
this.shootTimer = 0.5;
break;
case 'snowpea':
this.shootInterval = this.level >= 2 ? 1.25 : 1.4;
this.damage = this.level >= 2 ? 25 : 20;
this.shootTimer = 0.5;
break;
case 'repeater':
this.shootInterval = 1.35;
this.damage = 20;
this.shootTimer = 0.5;
this.repeaterSecondPea = 0;
this.repeaterThirdPea = 0;
break;
case 'wallnut':
this.maxHealth = this.level === 3 ? 2400 : (this.level === 2 ? 1800 : 1200);
this.armorReduction = this.level >= 2 ? 0.15 : 0;
this.recoilDps = this.level === 3 ? 18 : 0; // Thorns on Tier 3!
break;
case 'cherrybomb':
this.fuse = 1.2;
this.fuseTimer = 0;
break;
case 'potatomine':
this.armTime = this.level === 3 ? 4.0 : (this.level === 2 ? 6.0 : 14.0);
this.armTimer = 0;
this.isArmed = false;
break;
case 'chomper':
this.maxHealth = 450;
this.isChewing = false;
this.chewTimer = 0;
this.chewDuration = this.level >= 2 ? 14.0 : 25.0;
this.bitesLeft = this.level === 3 ? 2 : 1;
break;
case 'jalapeno':
this.fuse = 0.9;
this.fuseTimer = 0;
break;
case 'sunshroom':
this.age = 0;
this.sunInterval = 12;
this.sunTimer = 3;
this.isBig = false;
break;
case 'fumeshroom':
this.shootInterval = 1.5;
this.shootTimer = 0.5;
break;
case 'lilypad':
this.maxHealth = 300;
this.isLilypad = true;
break;
case 'gravebuster':
this.maxHealth = 300;
this.bustTimer = 0;
this.bustDuration = 5.0;
break;
case 'flowerpot':
this.maxHealth = 300;
this.isPot = true;
break;
case 'cabbagepult':
this.damage = 40;
this.shootInterval = 2.0;
this.shootTimer = 0.8;
break;
case 'melonpult':
this.damage = 80;
this.shootInterval = 2.4;
this.shootTimer = 1.0;
break;
}
this.health = this.maxHealth;
}
update(dt, gameState) {
this.animTime += dt;
if (this.health <= 0) {
this.toRemove = true;
return;
}
// 1. Sunflower & Sun-shroom
if (this.type === 'sunflower') {
this.sunTimer += dt;
if (this.sunTimer >= this.sunInterval) {
this.sunTimer = 0;
gameState.spawnSun(this.x + (Math.random() * 20 - 10), this.y + (Math.random() * 20 - 10), this.sunAmount || 25, false);
// Tier 3: Heal neighboring plants!
if (this.level === 3) {
for (const p of gameState.plants) {
if (p !== this && Math.abs(p.col - this.col) <= 1 && Math.abs(p.row - this.row) <= 1 && p.health > 0) {
p.health = Math.min(p.maxHealth, p.health + 30);
gameState.combat.createSplash(p.x, p.y, '#80deea', 6);
}
}
}
}
} else if (this.type === 'sunshroom') {
this.age += dt;
if (this.age > 50 && !this.isBig) {
this.isBig = true;
}
this.sunTimer += dt;
if (this.sunTimer >= this.sunInterval) {
this.sunTimer = 0;
const val = this.isBig ? 25 : 15;
gameState.spawnSun(this.x + (Math.random() * 16 - 8), this.y + (Math.random() * 16 - 8), val, false);
}
}
// 2. Peashooter & SnowPea
if (this.type === 'peashooter' || this.type === 'snowpea') {
this.shootTimer += dt;
if (this.shootTimer >= this.shootInterval) {
const hasZombie = gameState.zombies.some(z => z.row === this.row && z.x > this.x && z.health > 0 && !z.toRemove);
if (hasZombie) {
this.shootTimer = 0;
if (this.type === 'peashooter' && this.level === 3) {
// Tier 3: Plasma Peashooter
gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, 'plasma', 0, this.damage));
} else if (this.type === 'snowpea' && this.level === 3) {
// Tier 3: Freeze Snowpea
const deepFreeze = Math.random() < 0.2;
gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, deepFreeze ? 'freeze_snowpea' : 'snowpea', 0, this.damage));
} else {
const isCrit = this.critChance && Math.random() < this.critChance;
const projType = this.type === 'snowpea' ? 'snowpea' : 'pea';
gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, projType, 0, this.damage, isCrit));
}
if (window.PvZAudio) window.PvZAudio.playShoot();
}
}
}
// 2.5 Cabbage-pult & Melon-pult (Lobbed Arc Shooters)
if (this.type === 'cabbagepult' || this.type === 'melonpult') {
this.shootTimer += dt;
if (this.shootTimer >= this.shootInterval) {
const targetZombie = gameState.zombies.find(z => z.row === this.row && z.x > this.x && z.health > 0 && !z.toRemove);
const hasBoss = gameState.boss && gameState.boss.isActive;
if (targetZombie || hasBoss) {
this.shootTimer = 0;
const targetX = targetZombie ? targetZombie.x : (hasBoss ? gameState.boss.x : 750);
const projType = this.type === 'melonpult' ? 'melon' : 'cabbage';
gameState.projectiles.push(new window.Projectile(this.x + 15, this.y - 10, this.row, projType, 0, this.damage, false, targetX));
if (window.PvZAudio) window.PvZAudio.playShoot();
}
}
}
// 3. Repeater (Double Pea)
if (this.type === 'repeater') {
this.shootTimer += dt;
if (this.repeaterThirdPea > 0) {
this.repeaterThirdPea -= dt;
if (this.repeaterThirdPea <= 0) {
const pType = this.level === 3 ? 'firepea' : 'pea';
gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22));
if (window.PvZAudio) window.PvZAudio.playShoot();
}
} else if (this.repeaterSecondPea > 0) {
this.repeaterSecondPea -= dt;
if (this.repeaterSecondPea <= 0) {
const pType = this.level === 3 ? 'firepea' : 'pea';
gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22));
if (window.PvZAudio) window.PvZAudio.playShoot();
}
} else if (this.shootTimer >= this.shootInterval) {
const hasZombie = gameState.zombies.some(z => z.row === this.row && z.x > this.x && z.health > 0 && !z.toRemove);
if (hasZombie) {
this.shootTimer = 0;
const pType = this.level === 3 ? 'firepea' : 'pea';
gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22));
this.repeaterSecondPea = 0.16;
if (this.level >= 2 && Math.random() < 0.25) {
this.repeaterThirdPea = 0.32; // Tier 2+ bonus 3rd pea!
}
if (window.PvZAudio) window.PvZAudio.playShoot();
}
}
}
// 4. Fume-shroom (Piercing Spore Cloud)
if (this.type === 'fumeshroom') {
this.shootTimer += dt;
if (this.shootTimer >= this.shootInterval) {
const hasZombie = gameState.zombies.some(z => z.row === this.row && z.x > this.x && (z.x - this.x) < 330 && z.health > 0 && !z.toRemove);
if (hasZombie) {
this.shootTimer = 0;
gameState.projectiles.push(new window.Projectile(this.x + 20, this.y - 8, this.row, 'fume'));
if (window.PvZAudio) window.PvZAudio.playShoot();
}
}
}
// 5. Potato Mine
if (this.type === 'potatomine') {
if (!this.isArmed) {
this.armTimer += dt;
if (this.armTimer >= this.armTime) {
this.isArmed = true;
}
} else {
// Explode if zombie steps on it
const victim = gameState.zombies.find(z => z.row === this.row && Math.abs(z.x - this.x) < 35 && z.health > 0 && !z.toRemove);
if (victim) {
this.toRemove = true;
gameState.combat.createExplosionParticles(this.x, this.y);
if (window.PvZAudio) window.PvZAudio.playMine();
if (gameState.achievements) gameState.achievements.trigger('spudow_master');
if (this.level === 3) {
// Tier 3: 3x3 Explosion!
gameState.explodeArea(this.col, this.row, 3);
} else {
for (const z of gameState.zombies) {
if (z.row === this.row && Math.abs(z.x - this.x) < 45 && z.health > 0 && !z.toRemove) {
z.takeDamage(1800);
gameState.addScore(250);
gameState.zombiesKilled++;
}
}
}
}
}
}
// 6. Chomper (Instant Swallow)
if (this.type === 'chomper') {
if (this.isChewing) {
this.chewTimer += dt;
if (this.chewTimer >= this.chewDuration) {
this.isChewing = false;
this.chewTimer = 0;
this.bitesLeft = this.level === 3 ? 2 : 1;
}
} else {
const victim = gameState.zombies.find(z => z.row === this.row && z.x > this.x && (z.x - this.x) < 110 && z.health > 0 && !z.toRemove);
if (victim) {
victim.takeDamage(9999);
gameState.addScore(200);
gameState.zombiesKilled++;
if (window.PvZAudio) window.PvZAudio.playGulp();
if (this.level === 3 && this.bitesLeft > 1) {
this.bitesLeft--;
// Still ready for another bite!
} else {
this.isChewing = true;
this.chewTimer = 0;
}
}
}
}
// 7. Cherry Bomb
if (this.type === 'cherrybomb') {
this.fuseTimer += dt;
if (this.fuseTimer >= this.fuse) {
this.toRemove = true;
gameState.explodeArea(this.col, this.row, 3);
if (window.PvZAudio) window.PvZAudio.playExplosion();
}
}
// 8. Jalapeno (Full Row Sizzle)
if (this.type === 'jalapeno') {
this.fuseTimer += dt;
if (this.fuseTimer >= this.fuse) {
this.toRemove = true;
if (window.PvZAudio) window.PvZAudio.playSizzle();
// Fiery blast along entire row
for (let c = 0; c < gameState.grid.cols; c++) {
const bounds = gameState.grid.getCellBounds(c, this.row);
gameState.combat.createExplosionParticles(bounds.centerX, bounds.centerY);
}
let killedAny = false;
for (const z of gameState.zombies) {
if (z.row === this.row && z.health > 0 && !z.toRemove) {
z.takeDamage(1800);
gameState.addScore(250);
gameState.zombiesKilled++;
killedAny = true;
}
}
if (killedAny && gameState.achievements) {
gameState.achievements.trigger('fire_jalapeno');
}
}
}
// 9. Grave Buster
if (this.type === 'gravebuster') {
this.bustTimer += dt;
if (this.bustTimer >= this.bustDuration) {
this.toRemove = true;
gameState.grid.removeGrave(this.col, this.row);
gameState.spawnCoin(this.x, this.y, 'gold');
if (window.PvZAudio) window.PvZAudio.playCoin();
}
}
}
takeDamage(amount) {
const reduced = amount * (1 - (this.armorReduction || 0));
this.health -= reduced;
if (this.health <= 0) {
this.toRemove = true;
}
}
draw(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
// Ground shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
ctx.beginPath();
ctx.ellipse(0, 32, 28, 10, 0, 0, Math.PI * 2);
ctx.fill();
const sway = Math.sin(this.animTime * 3) * 0.05;
ctx.rotate(sway);
switch (this.type) {
case 'sunflower':
this.drawSunflower(ctx);
break;
case 'peashooter':
this.drawPeashooter(ctx, false);
break;
case 'snowpea':
this.drawPeashooter(ctx, true);
break;
case 'repeater':
this.drawRepeater(ctx);
break;
case 'wallnut':
this.drawWallnut(ctx);
break;
case 'cherrybomb':
this.drawCherryBomb(ctx);
break;
case 'potatomine':
this.drawPotatoMine(ctx);
break;
case 'chomper':
this.drawChomper(ctx);
break;
case 'jalapeno':
this.drawJalapeno(ctx);
break;
case 'sunshroom':
this.drawSunshroom(ctx);
break;
case 'fumeshroom':
this.drawFumeshroom(ctx);
break;
case 'lilypad':
this.drawLilypad(ctx);
break;
case 'gravebuster':
this.drawGravebuster(ctx);
break;
case 'flowerpot':
this.drawFlowerPot(ctx);
break;
case 'cabbagepult':
this.drawCabbagepult(ctx);
break;
case 'melonpult':
this.drawMelonpult(ctx);
break;
}
// Tier Stars (Level 2 & 3)
if (this.level > 1) {
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = this.level === 3 ? '#00e5ff' : '#ffd54f';
ctx.shadowColor = this.level === 3 ? 'rgba(0, 229, 255, 0.8)' : 'rgba(255, 213, 79, 0.8)';
ctx.shadowBlur = 4;
const stars = this.level === 3 ? '★★★' : '★★';
ctx.fillText(stars, 0, -42);
ctx.shadowBlur = 0;
}
ctx.restore();
}
drawSunflower(ctx) {
ctx.fillStyle = '#4caf50';
ctx.beginPath();
ctx.ellipse(-18, 18, 16, 7, -0.4, 0, Math.PI * 2);
ctx.ellipse(18, 18, 16, 7, 0.4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#2e7d32';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(0, 30);
ctx.quadraticCurveTo(-4, 10, 0, -5);
ctx.stroke();
const petalCount = 12;
const petalPulse = 1 + Math.sin(this.animTime * 4) * 0.06;
const isAboutToSpawn = (this.sunInterval - this.sunTimer) < 1.5;
ctx.save();
ctx.translate(0, -10);
for (let i = 0; i < petalCount; i++) {
const angle = (i * (Math.PI * 2) / petalCount) + this.animTime * 0.4;
ctx.save();
ctx.rotate(angle);
ctx.fillStyle = isAboutToSpawn ? '#fff59d' : '#fbc02d';
ctx.beginPath();
ctx.ellipse(0, -22 * petalPulse, 8, 14 * petalPulse, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
ctx.fillStyle = isAboutToSpawn ? '#8d6e63' : '#5d4037';
ctx.beginPath();
ctx.arc(0, 0, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#212121';
ctx.beginPath();
ctx.arc(-6, -2, 2.5, 0, Math.PI * 2);
ctx.arc(6, -2, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#212121';
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.arc(0, 2, 8, 0.2, Math.PI - 0.2);
ctx.stroke();
ctx.restore();
}
drawPeashooter(ctx, isSnow) {
const mainColor = isSnow ? '#29b6f6' : '#4caf50';
const darkColor = isSnow ? '#0288d1' : '#2e7d32';
ctx.fillStyle = darkColor;
ctx.beginPath();
ctx.ellipse(-20, 22, 16, 8, -0.3, 0, Math.PI * 2);
ctx.ellipse(20, 22, 16, 8, 0.3, 0, Math.PI * 2);
ctx.ellipse(0, 24, 18, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = darkColor;
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(0, 24);
ctx.quadraticCurveTo(-10, 4, -4, -10);
ctx.stroke();
ctx.save();
ctx.translate(-4, -14);
const headSquish = Math.sin(this.animTime * 5) * 0.05;
ctx.scale(1 + headSquish, 1 - headSquish);
ctx.fillStyle = mainColor;
ctx.beginPath();
ctx.arc(0, 0, 20, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(18, 0, 10, 13, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = darkColor;
ctx.beginPath();
ctx.ellipse(22, 0, 5, 9, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(6, -8, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000000';
ctx.beginPath();
ctx.arc(8, -8, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
drawRepeater(ctx) {
this.drawPeashooter(ctx, false);
// Extra top leaves/eyebrow
ctx.save();
ctx.translate(-4, -14);
ctx.fillStyle = '#1b5e20';
ctx.beginPath();
ctx.ellipse(-16, -12, 12, 6, -0.6, 0, Math.PI * 2);
ctx.ellipse(-14, 0, 10, 5, -0.2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
drawWallnut(ctx) {
const healthRatio = this.health / this.maxHealth;
ctx.fillStyle = '#8d6e63';
ctx.beginPath();
ctx.ellipse(0, 2, 22, 28, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#a1887f';
ctx.beginPath();
ctx.ellipse(-6, -4, 14, 20, -0.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.ellipse(-8, -8, 5, 7, 0, 0, Math.PI * 2);
ctx.ellipse(8, -8, 5, 7, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3e2723';
ctx.beginPath();
ctx.arc(-6, -8, 2.5, 0, Math.PI * 2);
ctx.arc(10, -8, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#3e2723';
ctx.lineWidth = 2;
ctx.beginPath();
if (healthRatio > 0.66) {
ctx.arc(0, 6, 6, 0.2, Math.PI - 0.2);
} else if (healthRatio > 0.33) {
ctx.moveTo(-6, 8);
ctx.lineTo(6, 8);
} else {
ctx.arc(0, 12, 6, Math.PI + 0.2, Math.PI * 2 - 0.2);
}
ctx.stroke();
if (healthRatio <= 0.66) {
ctx.strokeStyle = '#3e2723';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(12, -18);
ctx.lineTo(6, -6);
ctx.lineTo(14, 4);
ctx.stroke();
}
if (healthRatio <= 0.33) {
ctx.strokeStyle = '#3e2723';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(-14, -14);
ctx.lineTo(-6, -2);
ctx.lineTo(-12, 10);
ctx.stroke();
}
}
drawPotatoMine(ctx) {
if (!this.isArmed) {
// Underground sprout
ctx.fillStyle = '#795548';
ctx.beginPath();
ctx.ellipse(0, 28, 14, 6, 0, 0, Math.PI * 2);
ctx.fill();
// Little green antenna
ctx.strokeStyle = '#4caf50';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, 28);
ctx.lineTo(0, 14);
ctx.stroke();
ctx.fillStyle = '#f44336';
ctx.beginPath();
ctx.arc(0, 14, 4, 0, Math.PI * 2);
ctx.fill();
} else {
// Emerged Potato
ctx.fillStyle = '#a1887f';
ctx.beginPath();
ctx.ellipse(0, 16, 20, 16, 0, 0, Math.PI * 2);
ctx.fill();
// Flashing red beacon
const flash = Math.sin(this.animTime * 12) > 0;
ctx.strokeStyle = '#3e2723';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, 2);
ctx.lineTo(0, -6);
ctx.stroke();
ctx.fillStyle = flash ? '#ff1744' : '#b71c1c';
ctx.beginPath();
ctx.arc(0, -6, 5, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(-6, 12, 4, 0, Math.PI * 2);
ctx.arc(6, 12, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(-5, 12, 2, 0, Math.PI * 2);
ctx.arc(7, 12, 2, 0, Math.PI * 2);
ctx.fill();
}
}
drawChomper(ctx) {
// Stem
ctx.strokeStyle = '#2e7d32';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.moveTo(0, 28);
ctx.quadraticCurveTo(-14, 0, -4, -12);
ctx.stroke();
// Purple Head
ctx.save();
ctx.translate(-4, -16);
const jawOpen = this.isChewing ? Math.sin(this.animTime * 10) * 0.15 + 0.1 : 0.45;
// Upper head
ctx.save();
ctx.rotate(-jawOpen);
ctx.fillStyle = '#7b1fa2';
ctx.beginPath();
ctx.arc(0, 0, 22, Math.PI, 0);
ctx.lineTo(26, 0);
ctx.fill();
// Teeth
ctx.fillStyle = '#fff';
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(i * 6, 0);
ctx.lineTo(i * 6 + 3, 6);
ctx.lineTo(i * 6 + 6, 0);
ctx.fill();
}
ctx.restore();
// Lower jaw
ctx.save();
ctx.rotate(jawOpen);
ctx.fillStyle = '#4a148c';
ctx.beginPath();
ctx.arc(0, 4, 18, 0, Math.PI);
ctx.fill();
ctx.restore();
ctx.restore();
}
drawJalapeno(ctx) {
const pulse = 1 + Math.sin(this.animTime * 15) * 0.1;
ctx.scale(pulse, pulse);
// Green cap
ctx.fillStyle = '#2e7d32';
ctx.beginPath();
ctx.arc(0, -26, 8, Math.PI, 0);
ctx.fill();
// Red pepper body
ctx.fillStyle = '#d50000';
ctx.beginPath();
ctx.moveTo(-10, -22);
ctx.quadraticCurveTo(-14, 10, 0, 28);
ctx.quadraticCurveTo(14, 10, 10, -22);
ctx.closePath();
ctx.fill();
// Furious Face
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(-4, -6, 3, 0, Math.PI * 2);
ctx.arc(4, -6, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(-4, -6, 1.5, 0, Math.PI * 2);
ctx.arc(4, -6, 1.5, 0, Math.PI * 2);
ctx.fill();
}
drawSunshroom(ctx) {
const scale = this.isBig ? 1.0 : 0.65;
ctx.scale(scale, scale);
// Stalk
ctx.fillStyle = '#fff9c4';
ctx.fillRect(-6, 4, 12, 22);
// Glowing Cap
ctx.fillStyle = '#fbc02d';
ctx.beginPath();
ctx.arc(0, 6, 18, Math.PI, 0);
ctx.fill();
// Orange spots
ctx.fillStyle = '#f57f17';
ctx.beginPath();
ctx.arc(-6, -2, 4, 0, Math.PI * 2);
ctx.arc(6, -2, 4, 0, Math.PI * 2);
ctx.fill();
}
drawFumeshroom(ctx) {
// Stalk
ctx.fillStyle = '#e1bee7';
ctx.fillRect(-7, 4, 14, 22);
// Purple Cap
ctx.fillStyle = '#8e24aa';
ctx.beginPath();
ctx.arc(0, 4, 20, Math.PI, 0);
ctx.fill();
// Spout snout
ctx.fillStyle = '#6a1b9a';
ctx.beginPath();
ctx.ellipse(16, 2, 7, 10, 0, 0, Math.PI * 2);
ctx.fill();
}
drawLilypad(ctx) {
ctx.fillStyle = '#2e7d32';
ctx.beginPath();
ctx.ellipse(0, 24, 32, 16, 0, 0.4, Math.PI * 2 - 0.4);
ctx.lineTo(0, 24);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#1b5e20';
ctx.lineWidth = 2;
ctx.stroke();
}
drawGravebuster(ctx) {
// Green roots wrapping around grave
ctx.strokeStyle = '#33691e';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.arc(0, 0, 24, 0, Math.PI * 2);
ctx.stroke();
}
drawCherryBomb(ctx) {
const pulse = 1 + (this.fuseTimer / this.fuse) * 0.4 + Math.sin(this.animTime * 15) * 0.1;
ctx.scale(pulse, pulse);
ctx.strokeStyle = '#2e7d32';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(0, -22);
ctx.lineTo(-14, 0);
ctx.moveTo(0, -22);
ctx.lineTo(14, 0);
ctx.stroke();
const flash = Math.sin(this.animTime * 20) > 0;
const cherryColor = flash ? '#ff1744' : '#b71c1c';
ctx.fillStyle = cherryColor;
ctx.beginPath();
ctx.arc(-14, 4, 16, 0, Math.PI * 2);
ctx.arc(14, 4, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(-16, 2, 4, 0, Math.PI * 2);
ctx.arc(12, 2, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(-15, 2, 2, 0, Math.PI * 2);
ctx.arc(13, 2, 2, 0, Math.PI * 2);
ctx.fill();
}
drawFlowerPot(ctx) {
// Terracotta clay pot
ctx.fillStyle = '#bf360c';
ctx.beginPath();
ctx.moveTo(-24, 8);
ctx.lineTo(24, 8);
ctx.lineTo(18, 32);
ctx.lineTo(-18, 32);
ctx.closePath();
ctx.fill();
// Rim
ctx.fillStyle = '#d84315';
ctx.beginPath();
ctx.roundRect(-26, 2, 52, 9, 3);
ctx.fill();
// Dark soil inside
ctx.fillStyle = '#3e2723';
ctx.beginPath();
ctx.ellipse(0, 5, 20, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Little green leaf sprout on pot edge
ctx.fillStyle = '#66bb6a';
ctx.beginPath();
ctx.ellipse(14, 2, 6, 3, -0.5, 0, Math.PI * 2);
ctx.fill();
}
drawCabbagepult(ctx) {
const bob = Math.sin(this.animTime * 3) * 1.5;
// Wooden catapult basket frame
ctx.fillStyle = '#5d4037';
ctx.fillRect(-16, 12, 32, 10);
ctx.fillStyle = '#8d6e63';
ctx.beginPath();
ctx.roundRect(-12, 0, 24, 14, 3);
ctx.fill();
// Catapult Arm
const armAngle = -0.4 + Math.sin(this.animTime * 4) * 0.1;
ctx.save();
ctx.translate(-4, 6);
ctx.rotate(armAngle);
ctx.strokeStyle = '#4e342e';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-24, -18);
ctx.stroke();
// Spoon basket
ctx.fillStyle = '#8d6e63';
ctx.beginPath();
ctx.arc(-26, -18, 10, 0, Math.PI * 2);
ctx.fill();
// Cabbage inside basket
ctx.fillStyle = '#4caf50';
ctx.beginPath();
ctx.arc(-26, -20, 8, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Main Cabbage Head Face
ctx.fillStyle = '#81c784';
ctx.beginPath();
ctx.arc(6, -10 + bob, 18, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#388e3c';
ctx.lineWidth = 2;
ctx.stroke();
// Eyes
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(2, -12 + bob, 4, 0, Math.PI * 2);
ctx.arc(12, -12 + bob, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(3, -12 + bob, 2, 0, Math.PI * 2);
ctx.arc(13, -12 + bob, 2, 0, Math.PI * 2);
ctx.fill();
}
drawMelonpult(ctx) {
const bob = Math.sin(this.animTime * 2.5) * 2;
// Heavy wooden frame
ctx.fillStyle = '#3e2723';
ctx.fillRect(-22, 10, 44, 14);
ctx.fillStyle = '#5d4037';
ctx.beginPath();
ctx.roundRect(-16, -2, 32, 16, 4);
ctx.fill();
// Heavy catapult lever
const armAngle = -0.5 + Math.sin(this.animTime * 3) * 0.1;
ctx.save();
ctx.translate(-6, 4);
ctx.rotate(armAngle);
ctx.strokeStyle = '#263238';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-28, -22);
ctx.stroke();
// Heavy steel basket
ctx.fillStyle = '#455a64';
ctx.beginPath();
ctx.arc(-30, -24, 14, 0, Math.PI * 2);
ctx.fill();
// Watermelon inside basket
ctx.fillStyle = '#2e7d32';
ctx.beginPath();
ctx.ellipse(-30, -26, 12, 9, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#a5d6a7';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
// Melonpult Head Face
ctx.fillStyle = '#388e3c';
ctx.beginPath();
ctx.ellipse(8, -14 + bob, 22, 17, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#a5d6a7';
ctx.lineWidth = 2.5;
ctx.stroke();
// Eyes
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(4, -16 + bob, 4.5, 0, Math.PI * 2);
ctx.arc(15, -16 + bob, 4.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(5, -16 + bob, 2, 0, Math.PI * 2);
ctx.arc(16, -16 + bob, 2, 0, Math.PI * 2);
ctx.fill();
}
};
+214
View File
@@ -0,0 +1,214 @@
window.Projectile = class Projectile {
constructor(x, y, row, type = 'pea', vy = 0, damage = 20, isCrit = false, targetX = 750) {
this.x = x;
this.y = y;
this.startX = x;
this.baseY = y;
this.targetX = targetX;
this.row = row;
this.type = type;
this.vy = vy; // for bouncing bowling ball
this.isCrit = isCrit;
this.isLobbed = type === 'cabbage' || type === 'melon';
this.apex = type === 'melon' ? 95 : (type === 'cabbage' ? 85 : 0);
let baseRadius = 9;
let baseSpeed = 380;
let baseDamage = damage;
if (type === 'bowling') {
baseRadius = 22; baseSpeed = 440; baseDamage = 400;
} else if (type === 'fume') {
baseRadius = 16; baseSpeed = 280;
} else if (type === 'plasma' || type === 'firepea') {
baseRadius = 12; baseSpeed = 460;
} else if (type === 'cabbage') {
baseRadius = 13; baseSpeed = 340; baseDamage = 40;
} else if (type === 'melon') {
baseRadius = 17; baseSpeed = 320; baseDamage = 80;
}
this.radius = isCrit ? baseRadius + 2 : baseRadius;
this.speed = baseSpeed;
this.damage = type === 'bowling' ? 400 : (isCrit ? baseDamage * 2 : baseDamage);
this.splashDamage = type === 'melon' ? 26 : 0;
this.splashRadius = type === 'melon' ? 80 : 0;
this.slows = type === 'snowpea';
this.freezes = type === 'freeze_snowpea';
this.pierces = type === 'fume' || type === 'plasma';
this.maxPierces = type === 'plasma' ? 2 : 999;
this.pierceHits = new Set();
this.lobProgress = 0;
this.toRemove = false;
this.animTime = 0;
}
update(dt) {
this.animTime += dt;
this.x += this.speed * dt;
if (this.isLobbed) {
const totalDist = Math.max(80, this.targetX - this.startX);
const curDist = this.x - this.startX;
const t = Math.max(0, Math.min(1, curDist / totalDist));
this.lobProgress = t;
this.y = this.baseY - 4 * this.apex * t * (1 - t);
} else {
this.y += this.vy * dt;
}
// Fume fades out after ~340px
if (this.type === 'fume' && (this.x - this.startX) > 340) {
this.toRemove = true;
}
// Bowling wall bounce
if (this.type === 'bowling') {
if (this.y < 120 && this.vy < 0) {
this.vy = -this.vy;
this.row = 0;
} else if (this.y > 550 && this.vy > 0) {
this.vy = -this.vy;
this.row = 4;
}
}
if (this.x > 960 || this.x < -40) {
this.toRemove = true;
}
}
draw(ctx) {
ctx.save();
if (this.type === 'snowpea' || this.type === 'freeze_snowpea') {
// Ice pea glow
const grad = ctx.createRadialGradient(this.x - 2, this.y - 2, 2, this.x, this.y, this.radius);
grad.addColorStop(0, '#e0f7fa');
grad.addColorStop(0.6, '#4dd0e1');
grad.addColorStop(1, '#00838f');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
// Ice sparkle trail
ctx.fillStyle = 'rgba(178, 235, 242, 0.5)';
ctx.beginPath();
ctx.arc(this.x - 8, this.y, this.radius * 0.5, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'plasma') {
// Tier 3 Plasma Peashooter
const grad = ctx.createRadialGradient(this.x, this.y, 2, this.x, this.y, this.radius + 4);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.4, '#00e5ff');
grad.addColorStop(0.8, '#2979ff');
grad.addColorStop(1, 'rgba(41, 121, 255, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius + 4, 0, Math.PI * 2);
ctx.fill();
// Electric spark trail
ctx.strokeStyle = '#e0f7fa';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(this.x - 12, this.y);
ctx.lineTo(this.x - 4, this.y + (Math.sin(this.animTime * 20) * 4));
ctx.stroke();
} else if (this.type === 'firepea') {
// Tier 3 Fiery Repeater
const grad = ctx.createRadialGradient(this.x, this.y, 2, this.x, this.y, this.radius + 4);
grad.addColorStop(0, '#fff9c4');
grad.addColorStop(0.4, '#ff9800');
grad.addColorStop(0.8, '#d50000');
grad.addColorStop(1, 'rgba(213, 0, 0, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius + 4, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'fume') {
// Purple spore cloud
const alpha = Math.max(0.2, 1 - (this.x - this.startX) / 340);
ctx.globalAlpha = alpha;
const grad = ctx.createRadialGradient(this.x, this.y, 2, this.x, this.y, this.radius);
grad.addColorStop(0, '#f3e5f5');
grad.addColorStop(0.5, '#ab47bc');
grad.addColorStop(1, '#6a1b9a');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
// Bubbles
ctx.fillStyle = '#ea80fc';
ctx.beginPath();
ctx.arc(this.x - 6, this.y - 4, 4, 0, Math.PI * 2);
ctx.arc(this.x + 5, this.y + 3, 3, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'cabbage') {
// Tumbling green cabbage head
ctx.translate(this.x, this.y);
ctx.rotate(this.animTime * 7);
ctx.fillStyle = '#66bb6a';
ctx.beginPath();
ctx.arc(0, 0, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#2e7d32';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(-2, -2, this.radius * 0.6, 0, Math.PI);
ctx.arc(3, 1, this.radius * 0.5, 0, Math.PI);
ctx.stroke();
} else if (this.type === 'melon') {
// Tumbling striped watermelon
ctx.translate(this.x, this.y);
ctx.rotate(this.animTime * 6);
ctx.fillStyle = '#2e7d32';
ctx.beginPath();
ctx.ellipse(0, 0, this.radius, this.radius * 0.8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#a5d6a7';
ctx.lineWidth = 2.5;
for (let i = -1; i <= 1; i++) {
ctx.beginPath();
ctx.arc(i * 5, 0, this.radius * 0.7, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
}
} else if (this.type === 'bowling') {
// Rolling Wall-nut
ctx.translate(this.x, this.y);
ctx.rotate(this.animTime * 10);
ctx.fillStyle = '#8d6e63';
ctx.beginPath();
ctx.arc(0, 0, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#5d4037';
ctx.beginPath();
ctx.arc(6, -6, 5, 0, Math.PI * 2);
ctx.fill();
} else {
// Standard or Critical pea
const grad = ctx.createRadialGradient(this.x - 3, this.y - 3, 2, this.x, this.y, this.radius);
if (this.isCrit) {
grad.addColorStop(0, '#fff59d');
grad.addColorStop(0.5, '#fbc02d');
grad.addColorStop(1, '#f57f17');
} else {
grad.addColorStop(0, '#a5d6a7');
grad.addColorStop(0.5, '#4caf50');
grad.addColorStop(1, '#2e7d32');
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
// Highlight spot
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.beginPath();
ctx.arc(this.x - 3, this.y - 3, 2.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
};
+446
View File
@@ -0,0 +1,446 @@
// Zombie Entities (Normal, Conehead, Buckethead, Flag, PoleVaulter, Newspaper, Football, DuckyTube)
window.Zombie = class Zombie {
constructor(row, type = 'normal', startX = 940) {
this.row = row;
this.type = type;
this.x = startX;
this.y = 0;
this.width = 60;
this.height = 90;
this.baseSpeed = 22; // default walking speed
this.eatingDps = 30;
this.isEating = false;
this.targetPlant = null;
this.eatTimer = 0;
this.slowTimer = 0;
this.hitFlashTimer = 0;
this.animTime = Math.random() * 5;
this.toRemove = false;
// Type specifics
this.hasPole = type === 'polevaulter';
this.isVaulting = false;
this.vaultTimer = 0;
this.hasPaper = type === 'newspaper';
this.paperHealth = 100;
this.isAngry = false;
switch (type) {
case 'conehead':
this.maxHealth = 200;
this.helmetHealth = 100;
break;
case 'buckethead':
this.maxHealth = 350;
this.helmetHealth = 250;
break;
case 'football':
this.maxHealth = 350;
this.helmetHealth = 250;
this.baseSpeed = 42;
break;
case 'polevaulter':
this.maxHealth = 180;
this.baseSpeed = 44; // Fast until vault
break;
case 'newspaper':
this.maxHealth = 200;
this.baseSpeed = 18;
break;
case 'duckytube':
this.maxHealth = 120;
this.baseSpeed = 24;
break;
case 'flag':
this.maxHealth = 110;
this.baseSpeed = 26;
break;
case 'normal':
default:
this.maxHealth = 100;
this.helmetHealth = 0;
break;
}
this.speed = this.baseSpeed;
this.health = this.maxHealth;
}
update(dt, gameState) {
this.animTime += dt;
if (this.health <= 0) {
this.toRemove = true;
return;
}
if (this.hitFlashTimer > 0) {
this.hitFlashTimer -= dt;
}
// Freeze / slow effect countdown
if (this.slowTimer > 0) {
this.slowTimer -= dt;
this.speed = this.baseSpeed * 0.5;
} else {
this.speed = this.baseSpeed;
}
// Pole Vaulter jump logic
if (this.isVaulting) {
this.vaultTimer += dt;
// Arc jump over plant (travels ~90px leftwards in 0.6s)
this.x -= 150 * dt;
if (this.vaultTimer >= 0.6) {
this.isVaulting = false;
this.baseSpeed = 22; // Normal speed after jumping
this.speed = 22;
}
return;
}
// Check if eating a plant
if (this.targetPlant) {
// Pole vaulter vaults over first encountered plant!
if (this.hasPole && !this.isVaulting) {
this.hasPole = false;
this.isVaulting = true;
this.vaultTimer = 0;
this.targetPlant = null;
this.isEating = false;
if (window.PvZAudio) window.PvZAudio.playVault();
return;
}
if (this.targetPlant.toRemove || this.targetPlant.health <= 0) {
this.targetPlant = null;
this.isEating = false;
} else {
this.isEating = true;
this.eatTimer += dt;
if (this.eatTimer >= 0.35) {
this.eatTimer = 0;
this.targetPlant.takeDamage(this.eatingDps * 0.35);
if (window.PvZAudio) window.PvZAudio.playChomp();
if (this.targetPlant && this.targetPlant.type === 'wallnut' && gameState && gameState.achievements) {
gameState.achievements.trigger('nut_wall');
}
if (this.targetPlant.toRemove || this.targetPlant.health <= 0) {
this.targetPlant = null;
this.isEating = false;
}
}
}
} else {
this.isEating = false;
}
// If not eating and not vaulting, walk forward
if (!this.isEating) {
this.x -= this.speed * dt;
}
}
takeDamage(amount, slows = false) {
this.hitFlashTimer = 0.08;
if (slows) {
this.slowTimer = 4.0;
}
// Newspaper mechanics
if (this.type === 'newspaper' && this.hasPaper) {
this.paperHealth -= amount;
if (this.paperHealth <= 0) {
this.hasPaper = false;
this.isAngry = true;
this.baseSpeed = 54; // Enraged run!
this.speed = this.slowTimer > 0 ? 27 : 54;
if (window.PvZAudio) window.PvZAudio.playStrike();
}
return;
}
this.health -= amount;
if (this.health <= 0) {
this.toRemove = true;
}
}
draw(ctx, gameState = null) {
ctx.save();
ctx.translate(this.x, this.y);
// Ground shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
ctx.beginPath();
ctx.ellipse(0, 42, 22, 8, 0, 0, Math.PI * 2);
ctx.fill();
const isDancing = gameState && gameState.tree && gameState.tree.cheats && gameState.tree.cheats.dance;
const danceBob = isDancing ? Math.sin(this.animTime * 12) * 6 : 0;
const walkBob = (Math.sin(this.animTime * (this.isAngry ? 12 : 5)) * 2) + danceBob;
const legAngle1 = Math.sin(this.animTime * (this.isAngry ? 10 : (isDancing ? 8 : 4))) * 0.35;
const legAngle2 = -legAngle1;
const armAngle = this.isEating
? (Math.sin(this.animTime * 8) * 0.2 + 0.5)
: (Math.sin(this.animTime * (this.isAngry ? 8 : (isDancing ? 8 : 3))) * 0.25 + (isDancing ? 0.9 : (this.isAngry ? 0.8 : 0.3)));
const isHit = this.hitFlashTimer > 0;
const isSlow = this.slowTimer > 0;
let skinColor = this.isAngry ? '#b71c1c' : '#809973';
let coatColor = '#5d4037';
let pantsColor = '#263238';
if (this.type === 'football') {
coatColor = '#b71c1c';
pantsColor = '#f5f5f5';
}
if (isSlow) {
skinColor = '#81d4fa';
coatColor = '#455a64';
}
if (isHit) {
skinColor = '#ffffff';
coatColor = '#e0e0e0';
}
// Vault jump vertical offset
if (this.isVaulting) {
const jumpProgress = this.vaultTimer / 0.6;
const jumpY = -Math.sin(jumpProgress * Math.PI) * 65;
ctx.translate(0, jumpY);
ctx.rotate(-0.3 + jumpProgress * 0.6);
} else {
ctx.translate(0, walkBob);
}
// Water ripple for Ducky Tube
if (this.type === 'duckytube') {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, 30, 30 + Math.sin(this.animTime * 4) * 4, 12, 0, 0, Math.PI * 2);
ctx.stroke();
}
// Back leg
ctx.save();
ctx.translate(6, 15);
ctx.rotate(legAngle2);
ctx.fillStyle = pantsColor;
ctx.fillRect(-5, 0, 10, 24);
ctx.fillStyle = '#1b1b1b';
ctx.fillRect(-7, 24, 14, 6);
ctx.restore();
// Front leg
ctx.save();
ctx.translate(-6, 15);
ctx.rotate(legAngle1);
ctx.fillStyle = pantsColor;
ctx.fillRect(-5, 0, 10, 24);
ctx.fillStyle = '#1b1b1b';
ctx.fillRect(-7, 24, 14, 6);
ctx.restore();
// Torso
ctx.fillStyle = coatColor;
ctx.fillRect(-14, -18, 26, 34);
// Tie
ctx.fillStyle = '#b71c1c';
ctx.beginPath();
ctx.moveTo(-1, -16);
ctx.lineTo(3, -16);
ctx.lineTo(2, -2);
ctx.lineTo(1, 4);
ctx.lineTo(0, -2);
ctx.fill();
// Ducky Inner Tube
if (this.type === 'duckytube') {
ctx.fillStyle = '#fbc02d';
ctx.beginPath();
ctx.ellipse(0, 16, 26, 14, 0, 0, Math.PI * 2);
ctx.fill();
// Duck beak
ctx.fillStyle = '#ff9800';
ctx.beginPath();
ctx.arc(-22, 12, 6, 0, Math.PI * 2);
ctx.fill();
}
// Arms & Items (Pole / Newspaper)
ctx.save();
ctx.translate(-8, -10);
ctx.rotate(armAngle);
ctx.fillStyle = coatColor;
ctx.fillRect(-18, -4, 20, 8);
ctx.fillStyle = skinColor;
ctx.beginPath();
ctx.arc(-20, 0, 5, 0, Math.PI * 2);
ctx.fill();
// Held Item
if (this.hasPole) {
// Long vault pole
ctx.strokeStyle = '#8d6e63';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(-20, 2);
ctx.lineTo(-65, -35);
ctx.stroke();
} else if (this.hasPaper) {
// Newspaper shield
ctx.fillStyle = '#f5f5f5';
ctx.strokeStyle = '#9e9e9e';
ctx.lineWidth = 1;
ctx.fillRect(-34, -18, 16, 32);
ctx.strokeRect(-34, -18, 16, 32);
// Newspaper lines
ctx.fillStyle = '#616161';
ctx.fillRect(-32, -14, 12, 2);
ctx.fillRect(-32, -8, 12, 2);
ctx.fillRect(-32, -2, 12, 2);
}
ctx.restore();
// Head
ctx.save();
ctx.translate(-2, -28);
const headTilt = this.isEating ? Math.sin(this.animTime * 12) * 0.15 : Math.sin(this.animTime * 3) * 0.05;
ctx.rotate(headTilt);
ctx.fillStyle = skinColor;
ctx.beginPath();
ctx.roundRect(-14, -16, 28, 30, [10, 10, 6, 6]);
ctx.fill();
// Eyes
ctx.fillStyle = this.isAngry ? '#ff5252' : '#fff9c4';
ctx.beginPath();
ctx.arc(-8, -4, 4.5, 0, Math.PI * 2);
ctx.arc(3, -4, 5.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(-9, -4, 2, 0, Math.PI * 2);
ctx.arc(2, -4, 2, 0, Math.PI * 2);
ctx.fill();
// Mouth
ctx.fillStyle = '#212121';
ctx.fillRect(-10, 6, 18, 5);
ctx.fillStyle = '#fff';
ctx.fillRect(-9, 6, 3, 3);
ctx.fillRect(-4, 6, 3, 3);
ctx.fillRect(1, 8, 3, 3);
// Tree of Wisdom Visual Cheats
if (gameState && gameState.tree && gameState.tree.cheats) {
// 1. Future Sunglasses
if (gameState.tree.cheats.future) {
ctx.fillStyle = '#00e5ff';
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 6;
ctx.fillRect(-12, -7, 22, 6);
ctx.fillStyle = '#18ffff';
ctx.fillRect(-10, -5, 18, 2);
ctx.shadowBlur = 0;
}
// 2. Mustache
if (gameState.tree.cheats.mustache) {
ctx.fillStyle = '#3e2723';
ctx.beginPath();
ctx.moveTo(-11, 4);
ctx.quadraticCurveTo(-4, 0, 0, 3);
ctx.quadraticCurveTo(4, 0, 9, 4);
ctx.quadraticCurveTo(0, 8, -11, 4);
ctx.fill();
}
}
// Headwear
if (this.type === 'conehead' && this.health > 100) {
ctx.fillStyle = '#ff6d00';
ctx.beginPath();
ctx.moveTo(-14, -14);
ctx.lineTo(14, -14);
ctx.lineTo(3, -48);
ctx.lineTo(-3, -48);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.moveTo(-8, -26);
ctx.lineTo(8, -26);
ctx.lineTo(6, -34);
ctx.lineTo(-6, -34);
ctx.closePath();
ctx.fill();
} else if (this.type === 'buckethead' && this.health > 100) {
ctx.fillStyle = '#90a4ae';
ctx.beginPath();
ctx.moveTo(-16, -14);
ctx.lineTo(16, -14);
ctx.lineTo(13, -44);
ctx.lineTo(-13, -44);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#546e7a';
ctx.lineWidth = 2;
ctx.stroke();
} else if (this.type === 'football' && this.health > 100) {
// Football Helmet
ctx.fillStyle = '#b71c1c';
ctx.beginPath();
ctx.arc(0, -10, 18, Math.PI, 0);
ctx.fill();
// White Stripe
ctx.fillStyle = '#ffffff';
ctx.fillRect(-3, -28, 6, 18);
// Face grill
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(-12, -4);
ctx.lineTo(-2, 4);
ctx.lineTo(8, -4);
ctx.stroke();
} else if (this.type === 'flag') {
ctx.strokeStyle = '#8d6e63';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(16, -55);
ctx.stroke();
ctx.fillStyle = '#d32f2f';
ctx.beginPath();
ctx.moveTo(16, -55);
ctx.lineTo(40, -45);
ctx.lineTo(16, -35);
ctx.closePath();
ctx.fill();
}
ctx.restore();
// Health bar if damaged
if (this.health < this.maxHealth) {
const pct = Math.max(0, this.health / this.maxHealth);
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(-20, -56, 40, 5);
ctx.fillStyle = pct > 0.4 ? '#8bc34a' : '#f44336';
ctx.fillRect(-20, -56, 40 * pct, 5);
}
ctx.restore();
}
};
+2422
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// Entry point for Plants vs Zombies game
document.addEventListener('DOMContentLoaded', () => {
const startModal = document.getElementById('start-modal');
const btnStart = document.getElementById('btn-start');
const engine = new window.GameEngine('gameCanvas');
function startGame() {
if (startModal) {
startModal.classList.add('hidden');
}
// Initialize Web Audio on user gesture
if (window.PvZAudio) {
window.PvZAudio.init();
}
engine.init();
}
if (btnStart) {
btnStart.addEventListener('click', startGame);
} else {
startGame();
}
// Register PWA Service Worker for offline play
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then((reg) => {
console.log('PvZ ServiceWorker registered:', reg.scope);
})
.catch((err) => {
console.warn('PvZ ServiceWorker registration failed:', err);
});
});
}
});
+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();
}
};
+443
View File
@@ -0,0 +1,443 @@
// Web Audio API Procedural Sound Synthesizer and Asset Helpers
window.PvZAudio = (function() {
let ctx = null;
let muted = false;
let musicInterval = null;
let musicPlaying = false;
let musicStep = 0;
const N = {
D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, Bb2: 116.54, B2: 123.47,
C3: 130.81, D3: 146.83, Eb3: 155.56, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, Bb3: 233.08, B3: 246.94,
C4: 261.63, Csharp4: 277.18, D4: 293.66, Eb4: 311.13, E4: 329.63, F4: 349.23, Fsharp4: 369.99, G4: 392.00, Gsharp4: 415.30, A4: 440.00, Bb4: 466.16, B4: 493.88,
C5: 523.25, Csharp5: 554.37, D5: 587.33, Eb5: 622.25, E5: 659.25, F5: 698.46, Fsharp5: 739.99, G5: 783.99, A5: 880.00, Bb5: 932.33, B5: 987.77,
C6: 1046.50
};
let currentThemeName = 'day';
const THEMES = {
day: {
tempo: 230,
bassType: 'triangle',
leadType: 'sine',
bassVol: 0.10,
leadVol: 0.08,
bass: [
N.A2, null, N.E3, null, N.A2, null, N.E3, null,
N.D3, null, N.A3, null, N.E3, null, N.G3, null
],
lead: [
N.A4, null, N.C5, N.E5, null, N.D5, N.C5, N.B4,
N.A4, null, N.C5, N.A4, null, N.B4, N.C5, N.B4
]
},
night: {
tempo: 280,
bassType: 'sine',
leadType: 'triangle',
bassVol: 0.12,
leadVol: 0.07,
bass: [
N.C3, null, N.G3, null, N.A2, null, N.E3, null,
N.F3, null, N.C4, null, N.G3, null, N.B3, null
],
lead: [
N.C5, null, N.Eb5, N.G5, null, N.F5, null, N.D5,
N.Eb5, null, N.C5, null, N.G4, null, N.Bb4, null
]
},
pool: {
tempo: 210,
bassType: 'triangle',
leadType: 'sine',
bassVol: 0.11,
leadVol: 0.09,
bass: [
N.G2, null, N.D3, N.G3, N.C3, null, N.G3, N.C4,
N.D3, null, N.A3, N.D4, N.G3, null, N.B3, null
],
lead: [
N.B4, N.D5, null, N.G5, N.Fsharp5, N.D5, null, N.B4,
N.C5, N.E5, null, N.A5, N.G5, N.E5, N.D5, null
]
},
fog: {
tempo: 310,
bassType: 'sawtooth',
leadType: 'triangle',
bassVol: 0.07,
leadVol: 0.07,
bass: [
N.D2, null, N.A2, null, N.D2, null, N.Bb2, null,
N.A2, null, N.F2, null, N.G2, null, N.A2, null
],
lead: [
N.D5, null, null, N.F5, null, N.E5, null, N.Csharp5,
N.D5, null, N.A4, null, null, N.F5, N.E5, null
]
},
roof: {
tempo: 190,
bassType: 'triangle',
leadType: 'sawtooth',
bassVol: 0.11,
leadVol: 0.06,
bass: [
N.F3, N.F3, N.C4, N.F3, N.Bb3, N.Bb3, N.F4, N.Bb3,
N.C3, N.C3, N.G3, N.C4, N.F3, null, N.C4, null
],
lead: [
N.F4, N.A4, N.C5, null, N.D5, N.F5, null, N.C5,
N.Bb4, N.D5, N.F5, null, N.A5, N.G5, N.F5, null
]
},
boss: {
tempo: 165,
bassType: 'sawtooth',
leadType: 'square',
bassVol: 0.09,
leadVol: 0.06,
bass: [
N.E2, N.E2, N.G2, N.E2, N.Bb2, N.A2, N.G2, N.E2,
N.E2, N.E2, N.B2, N.Bb2, N.A2, N.G2, N.E2, N.D3
],
lead: [
N.E5, N.E5, N.G5, null, N.Bb5, N.A5, N.G5, N.E5,
null, N.B5, N.Bb5, N.A5, null, N.G5, N.F5, N.E5
]
}
};
function initCtx() {
if (!ctx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
ctx = new AudioContext();
}
}
if (ctx && ctx.state === 'suspended') {
ctx.resume();
}
}
function playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(startVol, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(endVol, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
} catch (e) {}
}
return {
init: initCtx,
toggleMute: function() {
muted = !muted;
return muted;
},
isMuted: function() {
return muted;
},
playShoot: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(600, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.25, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.12);
} catch (e) {}
},
playHit: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(180, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.08);
} catch (e) {}
},
playSun: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
notes.forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.05);
gain.gain.setValueAtTime(0.18, ctx.currentTime + idx * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + idx * 0.05 + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + idx * 0.05);
osc.stop(ctx.currentTime + idx * 0.05 + 0.2);
});
} catch (e) {}
},
playPlant: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(220, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (e) {}
},
playChomp: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(140, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.1);
} catch (e) {}
},
playExplosion: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
// Noise buffer for explosion
const bufferSize = ctx.sampleRate * 0.5;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.5);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.5, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch (e) {}
},
playMower: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(90, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(240, ctx.currentTime + 1.2);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 1.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 1.2);
} catch (e) {}
},
playCoin: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(987.77, ctx.currentTime); // B5
osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.06); // E6
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
},
playSizzle: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const bufferSize = ctx.sampleRate * 0.8;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.8);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch (e) {}
},
playMine: function() {
this.playExplosion();
playTone(550, 'sine', 0.15, 0.3, 0.01);
},
playVault: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(180, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(620, ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.25, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
},
playGulp: function() {
playTone(180, 'sine', 0.18, 0.35, 0.01);
setTimeout(() => playTone(120, 'sine', 0.22, 0.3, 0.01), 90);
},
playStrike: function() {
playTone(240, 'square', 0.1, 0.3, 0.01);
playTone(120, 'sawtooth', 0.2, 0.35, 0.01);
},
playHugeWave: function() {
playTone(180, 'sawtooth', 0.6, 0.35, 0.01);
},
playWin: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const fanfare = [392, 523, 659, 784, 1046];
fanfare.forEach((f, i) => {
setTimeout(() => playTone(f, 'triangle', 0.35, 0.3, 0.01), i * 160);
});
},
playLose: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const doom = [400, 360, 320, 280, 220];
doom.forEach((f, i) => {
setTimeout(() => playTone(f, 'sawtooth', 0.4, 0.3, 0.01), i * 200);
});
},
// Background Music (Multi-Biome Procedural Chiptune Synthesizer)
startMusic: function(themeName = 'day') {
const targetTheme = THEMES[themeName] ? themeName : 'day';
if (musicPlaying && currentThemeName === targetTheme && musicInterval) {
return;
}
currentThemeName = targetTheme;
musicPlaying = true;
initCtx();
if (musicInterval) {
clearInterval(musicInterval);
musicInterval = null;
}
musicStep = 0;
const theme = THEMES[currentThemeName];
musicInterval = setInterval(() => {
if (!musicPlaying || muted) return;
const b = theme.bass[musicStep % theme.bass.length];
const l = theme.lead[musicStep % theme.lead.length];
if (b) {
playTone(b, theme.bassType, (theme.tempo / 1000) * 1.05, theme.bassVol, 0.001);
}
if (l) {
playTone(l, theme.leadType, (theme.tempo / 1000) * 0.95, theme.leadVol, 0.001);
}
musicStep++;
}, theme.tempo);
},
setTheme: function(themeName) {
if (musicPlaying) {
this.startMusic(themeName);
} else {
currentThemeName = THEMES[themeName] ? themeName : 'day';
}
},
getTheme: function() {
return currentThemeName;
},
stopMusic: function() {
musicPlaying = false;
if (musicInterval) {
clearInterval(musicInterval);
musicInterval = null;
}
},
toggleMusic: function() {
if (musicPlaying) {
this.stopMusic();
return false;
} else {
this.startMusic(currentThemeName);
return true;
}
},
isMusicPlaying: function() {
return musicPlaying;
}
};
})();
+394
View File
@@ -0,0 +1,394 @@
// Autonomous Game Testing API for Plants vs. Zombies
window.PvZTestAPI = (function() {
let engine = null;
return {
setEngine: function(e) {
engine = e;
},
getEngine: function() {
return engine;
},
getState: function() {
if (!engine) return null;
return {
isRunning: engine.isRunning,
isGameOver: engine.isGameOver,
isVictory: engine.isVictory,
currentMode: engine.currentMode,
currentLevel: engine.currentLevel,
sun: engine.economy.sun,
coins: engine.economy.totalCoins,
plantsCount: engine.plants.length,
zombiesCount: engine.zombies.length,
projectilesCount: engine.projectiles.length,
plants: engine.plants.map(p => ({ type: p.type, col: p.col, row: p.row, health: p.health })),
zombies: engine.zombies.map(z => ({ type: z.type, row: z.row, x: Math.round(z.x), health: z.health, isEating: z.isEating }))
};
},
clearField: function() {
if (!engine) return;
engine.plants = [];
engine.zombies = [];
engine.projectiles = [];
for (let r = 0; r < engine.grid.rows; r++) {
for (let c = 0; c < engine.grid.cols; c++) {
engine.grid.grid[r][c] = null;
}
}
},
spawnZombie: function(row, type = 'normal', x = 850) {
if (!engine) return null;
const z = new window.Zombie(row, type, x);
const b = engine.grid.getCellBounds(0, row);
z.y = b.centerY - 5;
engine.zombies.push(z);
return z;
},
plant: function(col, row, type) {
if (!engine) return null;
const b = engine.grid.getCellBounds(col, row);
const p = new window.Plant(type, col, row, b.x, b.y, b.w, b.h);
engine.grid.setPlant(col, row, p);
engine.plants.push(p);
return p;
},
step: function(dt = 0.05, ticks = 1) {
if (!engine) return;
for (let i = 0; i < ticks; i++) {
engine.update(dt);
}
},
runAllTests: function() {
const results = [];
let passed = 0;
function assert(name, condition, errorMsg) {
if (condition) {
results.push({ name, status: 'PASS' });
passed++;
} else {
results.push({ name, status: 'FAIL', error: errorMsg });
}
}
if (!engine) {
return { total: 0, passed: 0, failed: 1, error: 'Engine not initialized' };
}
const origMode = engine.currentMode;
const origLevel = engine.currentLevel;
try {
// Test 1: Peashooter Combat Test
this.clearField();
engine.startLevel(1, 'adventure');
this.clearField();
const p = this.plant(0, 0, 'peashooter');
const z = this.spawnZombie(0, 'normal', 500);
const startHealth = z.health;
// Step simulation until pea hits
this.step(0.1, 40); // 4 seconds of game time
assert('Peashooter damages zombie', z.health < startHealth, `Zombie health did not decrease: ${z.health}`);
// Test 2: Zombie Eating & Resume Movement Bug Test
this.clearField();
const testNut = this.plant(2, 1, 'wallnut');
testNut.health = 40; // low health so it gets eaten fast
const eatingZombie = this.spawnZombie(1, 'normal', testNut.x + 20);
// Step until zombie touches and eats nut
this.step(0.1, 20);
assert('Zombie begins eating plant', eatingZombie.isEating === true || testNut.health <= 0, 'Zombie did not start eating');
// Step until nut is consumed
this.step(0.1, 40);
const nutGone = engine.plants.length === 0 || testNut.health <= 0;
assert('Plant is consumed', nutGone, 'Plant was not consumed');
assert('Zombie resumes walking after plant death (Bug Fix)', eatingZombie.isEating === false, `isEating remained true!`);
// Test 3: Potato Mine Arming and Spudow Explosion
this.clearField();
const mine = this.plant(1, 2, 'potatomine');
assert('Potato mine starts unarmed', mine.isArmed === false, 'Mine started armed');
mine.armTimer = mine.armTime; // fast-forward arming
this.step(0.1, 2);
assert('Potato mine is armed', mine.isArmed === true, 'Mine did not arm');
const boomZombie = this.spawnZombie(2, 'normal', mine.x + 20);
this.step(0.1, 10);
assert('Potato mine explodes on contact', mine.toRemove === true || boomZombie.health <= 0, 'Mine did not explode');
// Test 4: Lawnmower Defense
this.clearField();
engine.grid.initLawnmowers();
const lawnZombie = this.spawnZombie(0, 'normal', 110); // triggers mower at x<=115
this.step(0.1, 10);
assert('Lawnmower activates on breach', engine.grid.lawnmowers[0].active === true || engine.grid.lawnmowers[0].used === true, 'Mower did not activate');
assert('Lawnmower kills intruder', lawnZombie.health <= 0 || lawnZombie.toRemove === true, 'Mower did not kill zombie');
// Test 5: I, Zombie Mode (No Instant Victory Bug Test)
this.clearField();
engine.startLevel(1, 'izombie');
assert('I, Zombie does NOT instantly trigger victory', engine.isVictory === false, 'Instant victory bug detected!');
assert('I, Zombie spawns 5 trophy sunflowers', engine.plants.filter(pl => pl.type === 'sunflower').length === 5, 'Incorrect sunflowers count');
assert('I, Zombie uses zombie deck', engine.economy.cards.some(c => c.id.startsWith('z_')), 'Zombie deck not equipped');
// Step simulation 10 ticks: game should remain running without instant win
this.step(0.1, 10);
assert('I, Zombie stays active during play', engine.isVictory === false && engine.isRunning === true, 'Unexpected victory or stop');
// Win simulation: destroy all sunflowers
for (const pl of engine.plants) {
if (pl.type === 'sunflower') pl.toRemove = true;
}
this.step(0.1, 2);
assert('I, Zombie triggers victory when all sunflowers are destroyed', engine.isVictory === true, 'Victory did not trigger upon sunflower defeat');
// Test 6: Auth & Profiles
window.PvZAuth.loginAsGuest();
assert('Guest mode detected', window.PvZAuth.isGuest() === true, 'Failed to login as guest');
const userRes = window.PvZAuth.login('autotester', 'testpass123');
assert('User registration & login succeeds', userRes.success === true && window.PvZAuth.getCurrentUser() === 'autotester', 'Login failed');
window.PvZAuth.saveUserData({ coins: 999, level: 3, highScore: 500, upgrades: {} });
const loaded = window.PvZAuth.getUserData();
assert('User profile data persists', loaded.coins === 999 && loaded.level === 3, 'Data did not persist');
window.PvZAuth.loginAsGuest(); // restore guest
// Test 7: Main Menu Navigation
engine.openMainMenu();
assert('Main menu pauses game', engine.isRunning === false, 'Game was not paused on menu open');
const startMod = document.getElementById('start-modal');
assert('Main menu opens start modal', startMod && !startMod.classList.contains('hidden'), 'Start modal was not shown');
// Test 8: Workshop, Materials & Plant Upgrades
if (engine.workshop) {
const ws = engine.workshop;
ws.materials.bioHumus = 50;
ws.materials.scrapMetal = 50;
ws.materials.crystals = 5;
engine.economy.totalCoins = 2000;
const pLevelBefore = ws.getPlantLevel('peashooter');
const upgradedT2 = ws.upgradePlant('peashooter');
assert('Workshop upgrades plant to Tier 2', upgradedT2 === true && ws.getPlantLevel('peashooter') === 2, 'Failed to upgrade to Tier 2');
const upgradedT3 = ws.upgradePlant('peashooter');
assert('Workshop upgrades plant to Tier 3', upgradedT3 === true && ws.getPlantLevel('peashooter') === 3, 'Failed to upgrade to Tier 3');
// Check perk upgrade
const perkUpgraded = ws.upgradePerk('hardyRoots');
assert('Workshop upgrades passive perk', perkUpgraded === true && ws.getPerkLevel('hardyRoots') === 1, 'Failed to upgrade perk');
// Check plant creation with upgraded tier and hardy roots
const cellB = engine.grid.getCellBounds(1, 0);
const t3Plant = engine.createPlant('peashooter', 1, 0, cellB);
assert('Planted plant inherits Tier 3 level', t3Plant.level === 3, `Expected level 3, got ${t3Plant.level}`);
assert('Hardy Roots perk boosts plant max HP', t3Plant.maxHealth > 300, `Expected boosted HP, got ${t3Plant.maxHealth}`);
// Restore peashooter level and perk for clean state
ws.plantLevels['peashooter'] = 1;
ws.perks['hardyRoots'] = 0;
} else {
assert('WorkshopSystem exists on engine', false, 'engine.workshop is undefined');
}
// Test 9: Zen Garden and Suburban Almanac
if (engine.zengarden) {
const zg = engine.zengarden;
zg.chocolateCount = 2;
zg.fertilizerCount = 3;
const feedRes = zg.feedSnail();
assert('Feeding Stinky snail with chocolate wakes and boosts snail', feedRes === true && zg.snail.isAwake === true && zg.snail.boostTimer > 0, 'Snail feed failed');
// Plant sprout in empty pot
const emptyIdx = zg.pots.findIndex(p => !p.plantType);
if (emptyIdx !== -1) {
const planted = zg.plantSprout('chomper');
assert('Zen Garden plants new sprout in empty pot', planted === true && zg.pots[emptyIdx].plantType === 'chomper', 'Sprout planting failed');
}
// Water thirsty plant
const thirstyPot = zg.pots.find(p => p.plantType && p.thirstTimer > 0);
if (thirstyPot) {
const watered = zg.waterPlant(thirstyPot.id);
assert('Watering thirsty plant succeeds', watered === true && thirstyPot.thirstTimer === 0, 'Watering failed');
} else {
zg.pots[0].thirstTimer = 10;
const watered = zg.waterPlant(zg.pots[0].id);
assert('Watering plant clears thirst timer', watered === true && zg.pots[0].thirstTimer === 0, 'Watering failed');
}
} else {
assert('ZenGardenSystem exists on engine', false, 'engine.zengarden is undefined');
}
if (engine.almanac) {
const alm = engine.almanac;
assert('Almanac has 13 plants defined', alm.plants.length >= 13, `Expected >= 13 plants, got ${alm.plants.length}`);
assert('Almanac has 8 zombies defined', alm.zombies.length >= 8, `Expected >= 8 zombies, got ${alm.zombies.length}`);
const yeti = alm.getItem('z_yeti');
assert('Almanac contains secret Zombie Yeti', yeti && yeti.id === 'z_yeti', 'Yeti not found in almanac');
} else {
assert('AlmanacSystem exists on engine', false, 'engine.almanac is undefined');
}
// Test 10: Achievements System
if (engine.achievements) {
const achs = engine.achievements;
assert('Achievements list contains 14+ achievements', achs.achievements.length >= 14, `Expected >= 14, got ${achs.achievements.length}`);
achs.trigger('first_plant');
const ach = achs.achievements.find(a => a.id === 'first_plant');
assert('first_plant achievement triggers and completes', ach && ach.completed === true, 'Achievement not completed');
const coinsBefore = engine.economy.totalCoins;
if (ach && !ach.claimed) {
const claimed = achs.claimReward('first_plant');
assert('Claiming achievement grants coins reward', claimed === true && engine.economy.totalCoins > coinsBefore, 'Reward claiming failed');
} else {
assert('Achievement reward already claimed or validated', true);
}
} else {
assert('AchievementSystem exists on engine', false, 'engine.achievements is undefined');
}
// Test 11: Garden Roguelike System
if (engine.roguelike) {
const rgl = engine.roguelike;
rgl.reset();
assert('Roguelike starts at floor 1 with 0 relics', rgl.currentFloor === 1 && rgl.activeRelics.length === 0, 'Reset failed');
const choices = rgl.generateDraftChoices();
assert('generateDraftChoices generates exactly 3 choices', choices && choices.length === 3, `Expected 3 choices, got ${choices ? choices.length : 0}`);
const selected = rgl.selectChoice(0);
assert('selectChoice successfully picks choice and increments floor', selected === true && rgl.currentFloor === 2, 'Select choice failed');
} else {
assert('RoguelikeSystem exists on engine', false, 'engine.roguelike is undefined');
}
// Test 12: Tree of Wisdom & Secret Cheats
if (engine.tree) {
const tree = engine.tree;
assert('TreeSystem has wisdom milestones defined', tree.wisdomList && tree.wisdomList.length >= 8, 'Wisdom list too small');
const initialHeight = tree.height;
engine.economy.totalCoins += 300;
const fed = tree.feedFertilizer();
assert('feedFertilizer increases height by 1', fed === true && tree.height === initialHeight + 1, 'Tree feeding failed');
const cheatState = tree.toggleCheat('mustache');
assert('toggleCheat toggles cheat state', typeof cheatState === 'boolean', 'Cheat toggle failed');
} else {
assert('TreeSystem exists on engine', false, 'engine.tree is undefined');
}
// Test 13: Dr. Zomboss Boss Fight
if (engine.boss) {
const boss = engine.boss;
boss.reset();
assert('Boss starts with 6000 HP and isActive true', boss.health === 6000 && boss.isActive === true, 'Boss init failed');
boss.takeDamage(500);
assert('Boss takeDamage accurately decreases HP', boss.health === 5500, `Expected 5500, got ${boss.health}`);
engine.economy.setBossDeck();
const hasPots = engine.economy.cards.some(c => c.id === 'flowerpot');
const hasMelon = engine.economy.cards.some(c => c.id === 'melonpult');
assert('Boss conveyor deck contains flowerpots and melon-pults', hasPots && hasMelon, 'Boss deck missing items');
} else {
assert('BossSystem exists on engine', false, 'engine.boss is undefined');
}
// Test 14: Leaderboard System
if (engine.leaderboards) {
const lb = engine.leaderboards;
assert('Leaderboards has 6 modes', Object.keys(lb.records).length >= 6, 'Missing leaderboard modes');
lb.submitScore('survival', 99, '99 Флагов (Тест)');
const entries = lb.getEntries('survival');
const top = entries[0];
assert('Leaderboard records and sorts top score', top && top.score === 99, 'Score submission failed');
} else {
assert('LeaderboardSystem exists on engine', false, 'engine.leaderboards is undefined');
}
// Test 15: Roof & Fog Biomes and Lobbed Projectiles
engine.grid.initBiome('roof');
assert('Roof biome initialized', engine.grid.biome === 'roof' && engine.grid.isRoof() === true, 'Roof biome init failed');
engine.grid.initBiome('fog');
assert('Fog biome initialized', engine.grid.biome === 'fog' && engine.grid.isFog() === true, 'Fog biome init failed');
const lobbedProj = new window.Projectile(100, 200, 1, 'melon', 80, 500, 200);
assert('Lobbed projectile initializes with arc parameters', lobbedProj.isLobbed === true && lobbedProj.targetX === 500, 'Lobbed proj init failed');
lobbedProj.update(0.1);
assert('Lobbed projectile progresses along trajectory', lobbedProj.lobProgress > 0, 'Trajectory update failed');
// Test 16: v2.1 Soundtrack, Themes & Boss Rush Mode
if (window.PvZAudio) {
window.PvZAudio.setTheme('boss');
assert('PvZAudio supports multi-biome themes', window.PvZAudio.getTheme() === 'boss', 'Theme switch failed');
window.PvZAudio.setTheme('day');
}
engine.applyTheme('cyberpunk');
assert('Visual themes switch to cyberpunk', engine.currentTheme === 'cyberpunk' && document.body.classList.contains('theme-cyberpunk'), 'Cyberpunk theme failed');
engine.applyTheme('winter');
assert('Visual themes switch to winter', engine.currentTheme === 'winter' && document.body.classList.contains('theme-winter'), 'Winter theme failed');
engine.applyTheme('classic');
assert('Visual themes reset to classic', engine.currentTheme === 'classic', 'Classic theme reset failed');
engine.wave.setLevel(1, 'bossrush');
assert('Boss Rush mode initializes 3 waves', engine.wave.totalWaves === 3 && engine.wave.biome === 'roof', 'Boss Rush init failed');
if (engine.leaderboards) {
assert('Leaderboard includes bossrush mode records', Array.isArray(engine.leaderboards.getEntries('bossrush')), 'Missing bossrush leaderboard');
}
// Test 17: Campaign System & 25 Levels Multi-Biome Progression
if (window.CampaignSystem) {
const worlds = window.CampaignSystem.WORLDS;
const levels = window.CampaignSystem.LEVELS;
assert('CampaignSystem defines 5 worlds', Array.isArray(worlds) && worlds.length === 5, `Expected 5 worlds, got ${worlds.length}`);
assert('CampaignSystem defines 25 levels', Array.isArray(levels) && levels.length === 25, `Expected 25 levels, got ${levels.length}`);
const lvl1 = window.CampaignSystem.getLevel(1);
const lvl11 = window.CampaignSystem.getLevel(11);
const lvl16 = window.CampaignSystem.getLevel(16);
const lvl25 = window.CampaignSystem.getLevel(25);
assert('Level 1-1 is Day Lawn biome', lvl1.code === '1-1' && lvl1.biome === 'day', 'Level 1 biome mismatch');
assert('Level 3-1 is Pool biome', lvl11.code === '3-1' && lvl11.biome === 'pool', 'Level 11 biome mismatch');
assert('Level 4-1 is Fog biome', lvl16.code === '4-1' && lvl16.biome === 'fog', 'Level 16 biome mismatch');
assert('Level 5-5 is Dr. Zomboss Boss fight', lvl25.code === '5-5' && lvl25.isBoss === true && lvl25.biome === 'roof', 'Level 25 boss mismatch');
engine.wave.setLevel(11, 'adventure');
assert('WaveSystem sets pool biome on level 11', engine.wave.biome === 'pool', 'Wave biome mismatch for lvl 11');
engine.wave.setLevel(16, 'adventure');
assert('WaveSystem sets fog biome on level 16', engine.wave.biome === 'fog', 'Wave biome mismatch for lvl 16');
engine.wave.setLevel(25, 'adventure');
assert('WaveSystem configures boss mode on level 25', engine.wave.mode === 'boss' && engine.wave.biome === 'roof', 'Wave boss mismatch for lvl 25');
const fmt = window.CampaignSystem.formatLevel(11);
assert('formatLevel formats code and world icon', fmt.fullLabel.includes('3-1') && fmt.worldIcon === '🏊', 'Format level mismatch');
} else {
assert('CampaignSystem is globally defined', false, 'CampaignSystem not found');
}
} catch (err) {
results.push({ name: 'Exception during test run', status: 'FAIL', error: err.toString() });
} finally {
// Restore original state
engine.startLevel(origLevel, origMode);
}
return {
total: results.length,
passed,
failed: results.length - passed,
results
};
}
};
})();
+29
View File
@@ -0,0 +1,29 @@
{
"name": "Plants vs. Zombies Web Edition",
"short_name": "PvZ Web",
"start_url": "./index.html",
"display": "standalone",
"orientation": "landscape-primary",
"background_color": "#1b5e20",
"theme_color": "#2e7d32",
"description": "Legendary Plants vs Zombies HTML5 web game with Zen Garden, Workshop, Roguelike, Boss Battles, and Leaderboards.",
"icons": [
{
"src": "favicon.ico",
"sizes": "32x32",
"type": "image/x-icon"
},
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+85
View File
@@ -0,0 +1,85 @@
// Plants vs Zombies PWA Service Worker (Offline Support)
const CACHE_NAME = 'pvz-web-v2.3';
const ASSETS_TO_CACHE = [
'./',
'./index.html',
'./manifest.json',
'./favicon.ico',
'./icon-192.png',
'./icon-512.png',
'./css/style.css',
'./js/main.js',
'./js/game.js',
'./js/entities/plant.js',
'./js/entities/zombie.js',
'./js/entities/projectile.js',
'./js/systems/grid.js',
'./js/systems/economy.js',
'./js/systems/combat.js',
'./js/systems/campaign.js',
'./js/systems/feedback.js',
'./js/systems/wave.js',
'./js/systems/workshop.js',
'./js/systems/zengarden.js',
'./js/systems/almanac.js',
'./js/systems/achievements.js',
'./js/systems/roguelike.js',
'./js/systems/tree.js',
'./js/systems/boss.js',
'./js/systems/leaderboard.js',
'./js/systems/auth.js',
'./js/utils/assets.js',
'./js/utils/test_api.js'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
}).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
);
}).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
// Navigation or static asset request
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
// Fetch in background to update cache
fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, networkResponse));
}
}).catch(() => {});
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
return networkResponse;
}
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return networkResponse;
}).catch(() => {
// Fallback to cached index.html for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('./index.html');
}
});
})
);
});
+94
View File
@@ -0,0 +1,94 @@
server {
listen 80;
listen [::]:80;
server_name ai.qpid.pro;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ai.qpid.pro;
ssl_certificate /etc/letsencrypt/live/ai.qpid.pro/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ai.qpid.pro/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Certbot webroot
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Bug Reports & Feedback API proxy
location /api/bugs {
proxy_pass http://127.0.0.1:8001/api/bugs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# User Auth & Profile Save API proxy
location /api/auth/ {
proxy_pass http://127.0.0.1:8001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin: list all users
location /api/users {
proxy_pass http://127.0.0.1:8001/api/users;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Gitea Git Service proxy
location = /gitea {
return 301 https://$host/gitea/;
}
location /gitea/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 120s;
proxy_connect_timeout 5s;
client_max_body_size 100M;
}
# SPA Fallback & Static asset serving
location / {
try_files $uri $uri/ /index.html;
}
}
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
listen [::]:80;
server_name ai.qpid.pro;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
+57
View File
@@ -0,0 +1,57 @@
server {
listen 80;
listen [::]:80;
server_name ai.qpid.pro;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ai.qpid.pro;
ssl_certificate /etc/letsencrypt/live/ai.qpid.pro/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ai.qpid.pro/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Certbot webroot
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Bug Reports & Feedback API proxy
location /api/bugs {
proxy_pass http://172.19.0.1:8001/api/bugs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA Fallback & Static asset serving
location / {
try_files $uri $uri/ /index.html;
}
}
+75
View File
@@ -0,0 +1,75 @@
server {
listen 80;
listen [::]:80;
server_name pvz.qpid.pro;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name pvz.qpid.pro;
ssl_certificate /etc/letsencrypt/live/pvz.qpid.pro/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pvz.qpid.pro/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Certbot webroot
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Bug Reports & Feedback API proxy
location /api/bugs {
proxy_pass http://127.0.0.1:8001/api/bugs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# User Auth & Profile Save API proxy
location /api/auth/ {
proxy_pass http://127.0.0.1:8001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin: list all users
location /api/users {
proxy_pass http://127.0.0.1:8001/api/users;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA Fallback & Static asset serving
location / {
try_files $uri $uri/ /index.html;
}
}
+21
View File
@@ -0,0 +1,21 @@
# Каталог проектов и рабочих сессий
Данная директория предназначена для хранения проектов и рабочих сессий.
## Организация структуры
Каждый отдельный проект / сессия размещается в отдельной поддиректории:
```text
projects/
├── <project-name-1>/
│ ├── ...
│ └── README.md
├── <project-name-2>/
│ ├── ...
│ └── README.md
```
### Рекомендации по именованию
- Название проекта на латинице в kebab-case (например: `bot-telegram`, `crm-dashboard`, `session-analysis`)
- При необходимости ведения журнала сессии в проекте можно создавать файл `SESSION.md` или `README.md` с контекстом и целями.
+110
View File
@@ -0,0 +1,110 @@
[
{
"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: адаптивная панель навигации, компактные карточки и вертикальный скролл."
},
{
"id": 4,
"author": "AutoTester",
"category": "feature",
"text": "Проверка отправки баг-репорта через тестовый пайплайн",
"status": "исправлено",
"timestamp": "2026-09-16 11:01",
"adminReply": "Тестовый пайплайн успешно проверен и функционирует штатно."
},
{
"id": 5,
"author": "Admin",
"category": "bug",
"text": "В режиме выживание, после начальной заставки пропадает строка растений и ничего не происходит. Играть не возможно, критический баг.",
"status": "исправлено",
"timestamp": "2026-09-16 11:07",
"adminReply": "Исправлено в v2.5: устранена ошибка ReferenceError: gameState is not defined в zombie.draw(ctx, gameState), вызывавшая падение цикла requestAnimationFrame при появлении зомби после заставки."
},
{
"id": 6,
"author": "Admin",
"category": "bug",
"text": "Не происходит смена режима, после выхода в главное меню, надо перезагружать страницу. Баг.",
"status": "исправлено",
"timestamp": "2026-09-16 11:07",
"adminReply": "Исправлено в v2.5: переключение режимов в меню теперь синхронно обновляет currentMode, закрывает все фоновые модалы, сбрасывает колоды и музыку, инициализируя корректный уровень для выбранного режима."
},
{
"id": 7,
"author": "Admin",
"category": "bug",
"text": "Дзен сад не работает, улитка бегает и начинает крутится на месте (зависает) растения ничего не дают, фича не работает",
"status": "исправлено",
"timestamp": "2026-09-16 11:08",
"adminReply": "Исправлено в v2.5: (1) устранено бесконечное дрожание направления улитки за счет пошагового приближения; (2) сбор монет на полу; (3) инструмент 'лейка' активен по умолчанию; (4) фоновое созревание и начисление монет."
},
{
"id": 8,
"author": "Admin",
"category": "bug",
"text": "Рогалик. Режим имеет такой же баг как и режим выживания.",
"status": "исправлено",
"timestamp": "2026-09-16 11:18",
"adminReply": "Исправлено в v2.5: устранен вылет при отрисовке зомби; исправлена ссылка на allCards при драфте семян; корректная инициализация колоды этажа."
},
{
"id": 9,
"author": "Admin",
"category": "bug",
"text": "Достижения не засчитываются, нужно их сохранять",
"status": "исправлено",
"timestamp": "2026-09-16 11:18",
"adminReply": "Исправлено в v2.5: исправлено затирание объекта данных в PvZAuth.saveUserData (теперь выполняется merge), добавлен fallback в localStorage для гостей, подключены триггеры nut_wall и ice_age."
},
{
"id": 10,
"author": "Admin",
"category": "bug",
"text": "Режим Зомбос не работает, не проходит начальная заставка... игра не начинается",
"status": "исправлено",
"timestamp": "2026-09-16 11:19",
"adminReply": "Исправлено в v2.5: убран ранний return из WaveSystem.update, блокировавший отсчет начальной заставки и запуск битвы с боссом."
},
{
"id": 11,
"author": "Admin",
"category": "bug",
"text": "\"Я Зомби\" Аналогичная проблема как и в режиме Зомбосс",
"status": "исправлено",
"timestamp": "2026-09-16 11:19",
"adminReply": "Исправлено в v2.5: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла."
},
{
"id": 12,
"author": "Admin",
"category": "bug",
"text": "Боулинг не работает, баг как и у Рогалика",
"status": "исправлено",
"timestamp": "2026-09-16 11:20",
"adminReply": "Исправлено в v2.5: устранен ReferenceError в zombie.draw, налажен спавн и рикошет шаров боулинга."
}
]
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro)
Zero external dependencies. Manages users, game progress profiles, leaderboards, and bug reports.
"""
import sqlite3
import json
import os
from datetime import datetime
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
DB_PATH = os.path.join(DATA_DIR, "pvz.db")
os.makedirs(DATA_DIR, exist_ok=True)
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER PRIMARY KEY,
coins INTEGER DEFAULT 150,
level INTEGER DEFAULT 1,
max_unlocked_level INTEGER DEFAULT 1,
high_score INTEGER DEFAULT 0,
theme TEXT DEFAULT 'classic',
upgrades TEXT DEFAULT '{}',
materials TEXT DEFAULT '{}',
plant_levels TEXT DEFAULT '{}',
perks TEXT DEFAULT '{}',
zen_garden TEXT DEFAULT '{}',
tree_of_wisdom TEXT DEFAULT '{}',
achievements TEXT DEFAULT '{}',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS leaderboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
player_name TEXT NOT NULL,
mode TEXT NOT NULL,
score INTEGER NOT NULL,
detail TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS bugs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
category TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT DEFAULT 'новый',
timestamp TEXT NOT NULL,
admin_reply TEXT
)
""")
conn.commit()
conn.close()
def get_default_profile_dict():
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": 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": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
{"id": 4, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
{"id": 5, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
{"id": 6, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
{"id": 7, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
{"id": 8, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0}
],
"fertilizerCount": 5,
"chocolateCount": 2,
"snail": {"name": "Стинки", "active": False, "speed": 60, "boostTimer": 0, "x": 50, "direction": 1},
"lastVisit": int(datetime.now().timestamp() * 1000)
},
"treeOfWisdom": {"height": 0, "cheats": {}},
"achievements": {}
}
def get_user_by_username(username):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username.strip(),))
user = cursor.fetchone()
conn.close()
return user
def register_user(username, password_hash, initial_data=None):
username = username.strip()
conn = get_connection()
cursor = conn.cursor()
try:
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
"INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)",
(username, password_hash, now_str, now_str)
)
user_id = cursor.lastrowid
profile_data = get_default_profile_dict()
if initial_data and isinstance(initial_data, dict):
for k, v in initial_data.items():
if v is not None:
profile_data[k] = v
cursor.execute("""
INSERT INTO profiles (
user_id, coins, level, max_unlocked_level, high_score, theme,
upgrades, materials, plant_levels, perks, zen_garden, tree_of_wisdom, achievements, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user_id,
profile_data.get("coins", 150),
profile_data.get("level", 1),
profile_data.get("maxUnlockedLevel", 1),
profile_data.get("highScore", 0),
profile_data.get("theme", "classic"),
json.dumps(profile_data.get("upgrades", {})),
json.dumps(profile_data.get("materials", {})),
json.dumps(profile_data.get("plantLevels", {})),
json.dumps(profile_data.get("perks", {})),
json.dumps(profile_data.get("zenGarden", {})),
json.dumps(profile_data.get("treeOfWisdom", {})),
json.dumps(profile_data.get("achievements", {})),
now_str
))
conn.commit()
conn.close()
return {"success": True, "user_id": user_id, "username": username, "data": profile_data}
except sqlite3.IntegrityError:
conn.close()
return {"success": False, "message": "Пользователь с таким именем уже существует"}
except Exception as e:
conn.close()
return {"success": False, "message": str(e)}
def authenticate_user(username, password_hash):
username = username.strip()
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,))
user = cursor.fetchone()
if not user:
conn.close()
return None
if user["password_hash"] != password_hash:
conn.close()
return {"success": False, "message": "Неверный пароль"}
# Update last login
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
conn.commit()
# Fetch profile
cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user["id"],))
p = cursor.fetchone()
conn.close()
if not p:
profile_dict = get_default_profile_dict()
else:
profile_dict = {
"coins": p["coins"],
"level": p["level"],
"maxUnlockedLevel": p["max_unlocked_level"],
"highScore": p["high_score"],
"theme": p["theme"],
"upgrades": json.loads(p["upgrades"] or "{}"),
"materials": json.loads(p["materials"] or "{}"),
"plantLevels": json.loads(p["plant_levels"] or "{}"),
"perks": json.loads(p["perks"] or "{}"),
"zenGarden": json.loads(p["zen_garden"] or "{}"),
"treeOfWisdom": json.loads(p["tree_of_wisdom"] or "{}"),
"achievements": json.loads(p["achievements"] or "{}")
}
return {"success": True, "user_id": user["id"], "username": user["username"], "data": profile_dict}
def save_user_profile(username, data):
username = username.strip()
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ? COLLATE NOCASE", (username,))
user = cursor.fetchone()
if not user:
conn.close()
return {"success": False, "message": "Пользователь не найден"}
user_id = user["id"]
cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,))
current = cursor.fetchone()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
coins = data.get("coins", current["coins"] if current else 150)
level = data.get("level", current["level"] if current else 1)
max_unlocked = data.get("maxUnlockedLevel", current["max_unlocked_level"] if current else 1)
high_score = data.get("highScore", current["high_score"] if current else 0)
theme = data.get("theme", current["theme"] if current else "classic")
# Merge JSON dictionaries
def merge_json(key, default):
cur_obj = json.loads(current[key] or "{}") if current and current[key] else default
new_val = data.get(key)
if new_val is not None and isinstance(new_val, dict):
cur_obj.update(new_val)
return json.dumps(cur_obj)
upgrades_json = merge_json("upgrades", {})
materials_json = merge_json("materials", {})
plant_levels_json = merge_json("plant_levels", {}) if "plant_levels" in data else (json.dumps(data["plantLevels"]) if "plantLevels" in data else (current["plant_levels"] if current else "{}"))
perks_json = merge_json("perks", {})
zen_garden_json = json.dumps(data["zenGarden"]) if "zenGarden" in data else (current["zen_garden"] if current else "{}")
tree_json = json.dumps(data["treeOfWisdom"]) if "treeOfWisdom" in data else (current["tree_of_wisdom"] if current else "{}")
achievements_json = json.dumps(data["achievements"]) if "achievements" in data else (current["achievements"] if current else "{}")
if current:
cursor.execute("""
UPDATE profiles SET
coins = ?, level = ?, max_unlocked_level = ?, high_score = ?, theme = ?,
upgrades = ?, materials = ?, plant_levels = ?, perks = ?,
zen_garden = ?, tree_of_wisdom = ?, achievements = ?, updated_at = ?
WHERE user_id = ?
""", (
coins, level, max_unlocked, high_score, theme,
upgrades_json, materials_json, plant_levels_json, perks_json,
zen_garden_json, tree_json, achievements_json, now_str,
user_id
))
else:
cursor.execute("""
INSERT INTO profiles (
user_id, coins, level, max_unlocked_level, high_score, theme,
upgrades, materials, plant_levels, perks, zen_garden, tree_of_wisdom, achievements, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user_id, coins, level, max_unlocked, high_score, theme,
upgrades_json, materials_json, plant_levels_json, perks_json,
zen_garden_json, tree_json, achievements_json, now_str
))
conn.commit()
conn.close()
return {"success": True}
def list_all_users():
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT
u.id, u.username, u.created_at, u.last_login,
p.coins, p.level, p.max_unlocked_level, p.high_score, p.theme,
p.materials, p.plant_levels, p.perks, p.upgrades, p.zen_garden, p.tree_of_wisdom, p.achievements
FROM users u
LEFT JOIN profiles p ON u.id = p.user_id
ORDER BY u.id ASC
""")
rows = cursor.fetchall()
conn.close()
result = []
for r in rows:
result.append({
"id": r["id"],
"username": r["username"],
"createdAt": r["created_at"],
"lastLogin": r["last_login"],
"coins": r["coins"] if r["coins"] is not None else 150,
"level": r["level"] if r["level"] is not None else 1,
"maxUnlockedLevel": r["max_unlocked_level"] if r["max_unlocked_level"] is not None else 1,
"highScore": r["high_score"] if r["high_score"] is not None else 0,
"theme": r["theme"] or "classic",
"materials": json.loads(r["materials"] or "{}"),
"plantLevels": json.loads(r["plant_levels"] or "{}"),
"perks": json.loads(r["perks"] or "{}"),
"upgrades": json.loads(r["upgrades"] or "{}"),
"zenGarden": json.loads(r["zen_garden"] or "{}"),
"treeOfWisdom": json.loads(r["tree_of_wisdom"] or "{}"),
"achievements": json.loads(r["achievements"] or "{}")
})
return result
if __name__ == "__main__":
init_db()
users = list_all_users()
print(f"Database initialized. Total registered users: {len(users)}")
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Backend Server for Plants vs. Zombies (ai.qpid.pro)
Zero external dependencies.
Manages:
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/save, /api/users)
- Community Bug Reports & Feedback (/api/bugs)
"""
import http.server
import json
import os
import sys
from datetime import datetime
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
import db
PORT = 8001
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json")
BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
os.makedirs(REPORTS_DIR, exist_ok=True)
db.init_db()
DEFAULT_BUGS = [
{
"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: адаптивная панель навигации, компактные карточки и вертикальный скролл."
}
]
def load_bugs():
if not os.path.exists(BUGS_JSON):
save_bugs(DEFAULT_BUGS)
return DEFAULT_BUGS
try:
with open(BUGS_JSON, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return DEFAULT_BUGS
def save_bugs(bugs):
with open(BUGS_JSON, "w", encoding="utf-8") as f:
json.dump(bugs, f, ensure_ascii=False, indent=2)
sync_markdown(bugs)
def sync_markdown(bugs):
lines = [
"# Журнал баг-репортов и обратной связи от игроков",
"",
"> Данный файл обновляется автоматически при поступлении новых баг-репортов из игры.",
"> При каждом обновлении и новой итерации разработки ассистент анализирует этот список.",
"",
f"**Всего репортов:** {len(bugs)} | **Последнее обновление:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
"| ID | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс |",
"|:---:|:---:|:---|:---:|:---:|:---|:---|"
]
for b in reversed(bugs):
bid = b.get("id", 0)
ts = b.get("timestamp", "")
author = b.get("author", "Аноним").replace("|", "/")
cat = b.get("category", "bug")
cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс"))
st = b.get("status", "новый")
st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый")
text = b.get("text", "").replace("|", "/").replace("\n", " ")
reply = (b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |")
lines.append("")
with open(BUGS_MD, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
class FeedbackHandler(http.server.BaseHTTPRequestHandler):
def _set_cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def do_OPTIONS(self):
self.send_response(204)
self._set_cors()
self.end_headers()
def do_GET(self):
path = self.path.split("?")[0]
# 1. Health check
if path == "/health":
self.send_response(200)
self._set_cors()
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
return
# 2. List Registered Users in Database
if path in ["/api/users", "/api/bugs/users", "/api/auth/users"]:
users = db.list_all_users()
data = json.dumps({"success": True, "users": users}, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
# 3. Bug Reports
if path.startswith("/api/bugs") or path.startswith("/bugs"):
bugs = load_bugs()
data = json.dumps(bugs, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(404)
self._set_cors()
self.end_headers()
def do_POST(self):
path = self.path.split("?")[0]
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try:
payload = json.loads(body) if body else {}
except Exception as e:
self.send_response(400)
self._set_cors()
self.end_headers()
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
return
# 1. User Authentication (Login & Auto-Registration)
if path in ["/api/auth/login", "/api/bugs/auth/login", "/api/auth/register"]:
username = str(payload.get("username", "")).strip()
password_hash = str(payload.get("passwordHash", "")).strip()
initial_data = payload.get("initialData")
if not username or not password_hash:
self.send_response(400)
self._set_cors()
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Имя пользователя и пароль обязательны"}).encode("utf-8"))
return
# Check if user exists
user = db.get_user_by_username(username)
if user:
res = db.authenticate_user(username, password_hash)
if not res or not res.get("success"):
self.send_response(401)
self._set_cors()
self.end_headers()
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный пароль"}).encode("utf-8"))
return
resp_json = res
else:
# Auto-register new user
res = db.register_user(username, password_hash, initial_data)
resp_json = res
data = json.dumps(resp_json, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
# 2. User Progress Profile Save & Sync
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
username = str(payload.get("username", "")).strip()
user_data = payload.get("data", {})
if not username or not user_data:
self.send_response(400)
self._set_cors()
self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Username and data are required"}).encode("utf-8"))
return
res = db.save_user_profile(username, user_data)
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
# 3. Bug Report Submission
if path.startswith("/api/bugs") or path.startswith("/bugs"):
text = str(payload.get("text", "")).strip()
if not text:
self.send_response(400)
self._set_cors()
self.end_headers()
self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8"))
return
author = str(payload.get("author", "Гость")).strip() or "Гость"
category = str(payload.get("category", "bug")).strip()
if category not in ["bug", "feature", "visual", "balance"]:
category = "bug"
bugs = load_bugs()
new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1
new_bug = {
"id": new_id,
"author": author[:30],
"category": category,
"text": text[:1000],
"status": "новый",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"adminReply": None
}
bugs.append(new_bug)
save_bugs(bugs)
resp_data = json.dumps({"success": True, "report": new_bug, "total": len(bugs)}, ensure_ascii=False).encode("utf-8")
self.send_response(201)
self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(resp_data)))
self.end_headers()
self.wfile.write(resp_data)
return
self.send_response(404)
self._set_cors()
self.end_headers()
def log_message(self, format, *args):
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] {self.address_string()} - {format%args}\n")
if __name__ == "__main__":
load_bugs()
server = http.server.HTTPServer(("0.0.0.0", PORT), FeedbackHandler)
print(f"PvZ Backend Server running on port {PORT} with SQLite database...")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down server.")
server.server_close()
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import os
import zlib
import struct
import math
def make_png(w, h, get_pixel):
def chunk(tag, data):
return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data) & 0xffffffff)
ihdr = struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0) # RGBA 8-bit
raw_rows = []
for y in range(h):
row = [0] # filter type 0
for x in range(w):
r, g, b, a = get_pixel(x, y, w, h)
row.extend([r, g, b, a])
raw_rows.append(bytes(row))
raw_data = b''.join(raw_rows)
idat = zlib.compress(raw_data, 9)
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', ihdr) + chunk(b'IDAT', idat) + chunk(b'IEND', b'')
def render_pvz_icon(x, y, w, h):
# Normalized coords (-1 to 1)
nx = (x - w / 2) / (w / 2)
ny = (y - h / 2) / (h / 2)
dist = math.hypot(nx, ny)
# Outer dark green circle border
if dist > 0.96:
return 0, 0, 0, 0
if dist > 0.90:
return 27, 94, 32, 255
# Background circle (lawn green gradient)
if dist <= 0.90:
# Sunflower petals or Peashooter head
# Let's draw stylized bright yellow sunflower with green peashooter center!
angle = math.atan2(ny, nx)
petal_r = 0.55 + 0.15 * math.sin(angle * 8)
# Petal check
if dist < petal_r and dist > 0.40:
return 255, 213, 79, 255 # Yellow sunflower petals
# Peashooter green head in center
center_dist = math.hypot(nx + 0.08, ny)
if center_dist < 0.35:
# Snout pointing right
snout = (nx > 0.05 and nx < 0.35 and abs(ny) < 0.12)
if snout:
return 76, 175, 80, 255
# Eye
eye_dist = math.hypot(nx - (-0.05), ny - (-0.12))
if eye_dist < 0.06:
return 255, 255, 255, 255
if eye_dist < 0.03:
return 0, 0, 0, 255
return 67, 160, 71, 255
# Green lawn background inside circle
grad = int(46 + (1.0 - dist) * 60)
return 27, min(255, grad + 80), 32, 255
def main():
game_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "game")
# Generate 192x192
p192 = make_png(192, 192, render_pvz_icon)
with open(os.path.join(game_dir, "icon-192.png"), "wb") as f:
f.write(p192)
print("Generated icon-192.png:", len(p192), "bytes")
# Generate 512x512
p512 = make_png(512, 512, render_pvz_icon)
with open(os.path.join(game_dir, "icon-512.png"), "wb") as f:
f.write(p512)
print("Generated icon-512.png:", len(p512), "bytes")
# Generate favicon.ico (valid 32x32 PNG inside or 32x32 favicon)
p32 = make_png(32, 32, render_pvz_icon)
with open(os.path.join(game_dir, "favicon.ico"), "wb") as f:
f.write(p32)
print("Generated favicon.ico:", len(p32), "bytes")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
CLI Tool to list, inspect, and manage registered users in Plants vs. Zombies database.
Usage:
python3 scripts/manage_users.py # List all registered users in a formatted table
python3 scripts/manage_users.py <user> # Show detailed full JSON profile for a specific user
"""
import sys
import json
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
import db
def print_user_table(users):
if not users:
print("\nВ базе данных пока нет зарегистрированных пользователей.")
print("Чтобы зарегистрироваться, откройте игру на https://ai.qpid.pro/ и нажмите кнопку «Войти».\n")
return
print("\n" + "=" * 90)
print(f"{'ID':<4} | {'Имя пользователя':<18} | {'Монеты':<8} | {'Уровень':<8} | {'Макс Ур':<8} | {'Рекорд':<8} | {'Создан':<19}")
print("-" * 90)
for u in users:
print(f"{u['id']:<4} | {u['username']:<18} | {u['coins']:<8} | {u['level']:<8} | {u['maxUnlockedLevel']:<8} | {u['highScore']:<8} | {u['createdAt']:<19}")
print("=" * 90)
print(f"Всего зарегистрировано пользователей: {len(users)}\n")
def print_user_detail(username):
users = db.list_all_users()
target = next((u for u in users if u["username"].lower() == username.lower()), None)
if not target:
print(f"\nПользователь '{username}' не найден в базе данных.\n")
return
print("\n" + "=" * 60)
print(f" Профиль пользователя: {target['username']} (ID: {target['id']})")
print("=" * 60)
print(f" Дата регистрации: {target['createdAt']}")
print(f" Последний вход: {target['lastLogin']}")
print(f" Текущий уровень: {target['level']}")
print(f" Макс. открытый: {target['maxUnlockedLevel']}")
print(f" Всего монет: {target['coins']}")
print(f" Рекорд очков: {target['highScore']}")
print(f" Тема оформления: {target['theme']}")
print("-" * 60)
print(" Материалы мастерской:")
for mat, count in target['materials'].items():
print(f" - {mat}: {count}")
print("-" * 60)
print(" Уровни растений:")
for plant, lvl in target['plantLevels'].items():
print(f" - {plant}: Уровень {lvl}")
print("-" * 60)
print(" Перки:")
for perk, lvl in target['perks'].items():
print(f" - {perk}: Уровень {lvl}")
print("-" * 60)
print(" Дерево Мудрости:")
print(f" - Высота: {target['treeOfWisdom'].get('height', 0)} футов")
print("=" * 60 + "\n")
if __name__ == "__main__":
db.init_db()
if len(sys.argv) > 1:
print_user_detail(sys.argv[1])
else:
users = db.list_all_users()
print_user_table(users)
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
echo "[$(date)] Checking and renewing SSL certificates..."
docker compose run --rm certbot renew --webroot --webroot-path=/var/www/certbot
docker compose exec nginx nginx -s reload || docker compose restart nginx
echo "[$(date)] Certificate check finished."
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
set -e
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
DOMAIN="ai.qpid.pro"
EMAIL="admin@qpid.pro"
echo "=== [1/5] Проверка и создание директорий Certbot ==="
mkdir -p "$PROJECT_DIR/certbot/conf" "$PROJECT_DIR/certbot/www"
chmod -R 755 "$PROJECT_DIR/certbot/www"
echo "=== [2/5] Проверка существующих SSL сертификатов ==="
CERT_PATH="$PROJECT_DIR/certbot/conf/live/$DOMAIN/fullchain.pem"
if [ -f "$CERT_PATH" ]; then
echo "Сертификат для $DOMAIN уже существует."
cp "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf.ssl" "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf"
docker compose up -d --build
echo "Nginx и Certbot запущены с существующим SSL."
else
echo "Сертификат не найден. Запуск временного HTTP-сервера для верификации ACME..."
cp "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf.http" "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf"
# Сборка и запуск nginx в HTTP-режиме
docker compose up -d --build nginx
echo "Ожидание готовности Nginx на порту 80..."
sleep 3
echo "=== [3/5] Запрос SSL-сертификата через Certbot ==="
SUCCESS=0
for ATTEMPT in 1 2 3; do
echo "Попытка $ATTEMPT получения сертификата через Certbot..."
if docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
-d "$DOMAIN" \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
--non-interactive; then
SUCCESS=1
echo "Сертификат успешно получен!"
break
else
echo "Попытка $ATTEMPT не удалась. Пауза 5 секунд..."
sleep 5
fi
done
if [ "$SUCCESS" -ne 1 ]; then
echo "❌ Ошибка: Не удалось получить SSL-сертификат для $DOMAIN."
echo "Проверьте, что DNS A-запись для $DOMAIN указывает на публичный IP этого сервера."
exit 1
fi
echo "=== [4/5] Применение полной SSL-конфигурации Nginx ==="
cp "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf.ssl" "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf"
docker compose restart nginx
fi
echo "=== [5/5] Настройка регулярного обновления сертификатов ==="
chmod +x "$PROJECT_DIR/scripts/renew-certs.sh"
# Добавление задачи обновления в cron (если еще не добавлена)
CRON_JOB="0 3 * * * $PROJECT_DIR/scripts/renew-certs.sh >> /var/log/certbot-renew.log 2>&1"
(crontab -l 2>/dev/null | grep -Fv "$PROJECT_DIR/scripts/renew-certs.sh" ; echo "$CRON_JOB") | crontab - || true
echo "=== Развертывание и настройка SSL успешно завершены! ==="
+738
View File
@@ -0,0 +1,738 @@
#!/usr/bin/env python3
"""
Autonomous Automated Test Runner for Plants vs. Zombies (ai.qpid.pro)
Tests static integrity, HTTP/HTTPS endpoints, JS scripts, and PvZ mechanics.
"""
import os
import sys
import re
import urllib.request
import ssl
import json
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAME_DIR = os.path.join(BASE_DIR, "game")
TEST_RESULTS = []
def record(name, status, detail=""):
TEST_RESULTS.append({"name": name, "status": status, "detail": detail})
symbol = "✅ PASS" if status == "PASS" else "❌ FAIL"
print(f"[{symbol}] {name}" + (f": {detail}" if detail else ""))
def test_file_structure():
print("\n--- 1. File Structure & Asset Verification ---")
required_files = [
"index.html",
"css/style.css",
"js/main.js",
"js/game.js",
"js/entities/plant.js",
"js/entities/zombie.js",
"js/entities/projectile.js",
"js/systems/grid.js",
"js/systems/economy.js",
"js/systems/combat.js",
"js/systems/workshop.js",
"js/systems/zengarden.js",
"js/systems/almanac.js",
"js/systems/achievements.js",
"js/systems/roguelike.js",
"js/systems/tree.js",
"js/systems/boss.js",
"js/systems/leaderboard.js",
"js/systems/campaign.js",
"js/systems/wave.js",
"js/systems/auth.js",
"js/systems/feedback.js",
"js/utils/assets.js",
"js/utils/test_api.js",
"manifest.json",
"sw.js",
"icon-192.png",
"icon-512.png",
"favicon.ico",
]
for rel in required_files:
path = os.path.join(GAME_DIR, rel)
if os.path.isfile(path) and os.path.getsize(path) > 50:
record(f"File exists: {rel}", "PASS", f"{os.path.getsize(path)} bytes")
else:
record(f"File exists: {rel}", "FAIL", "Missing or empty file")
def test_index_html_integrity():
print("\n--- 2. HTML Integrity & Script Links ---")
index_path = os.path.join(GAME_DIR, "index.html")
with open(index_path, "r", encoding="utf-8") as f:
content = f.read()
# Check top-nav-bar and buttons
assert_elements = [
("Top nav bar present", r'<header class="top-nav-bar">'),
("Main Menu button present", r'id="btn-menu"'),
("Profile badge present", r'id="user-profile-badge"'),
("Speed button present", r'id="btn-speed"'),
("Music button present", r'id="btn-music"'),
("Sound mute button present", r'id="btn-mute"'),
("Auth modal dialog present", r'id="auth-modal"'),
("Auth username input present", r'id="auth-username"'),
("Auth password input present", r'id="auth-password"'),
("Auth guest button present", r'id="btn-auth-guest"'),
("Game Over Menu button present", r'id="btn-menu-lose"'),
("Victory Menu button present", r'id="btn-menu-win"'),
("PvZAuth script loaded", r'src="js/systems/auth.js"'),
("PvZTestAPI script loaded", r'src="js/utils/test_api.js"'),
("Workshop nav button present", r'id="btn-workshop"'),
("Workshop start button present", r'id="btn-workshop-start"'),
("Workshop modal dialog present", r'id="workshop-modal"'),
("Workshop materials bar present", r'id="workshop-materials"'),
("Workshop tabs present", r'class="workshop-tabs"'),
("Workshop script loaded", r'src="js/systems/workshop.js"'),
("Zen Garden nav button present", r'id="btn-zengarden"'),
("Zen Garden start button present", r'id="btn-zengarden-start"'),
("Zen Garden modal dialog present", r'id="zengarden-modal"'),
("Zen Garden canvas present", r'id="zenCanvas"'),
("Zen Garden script loaded", r'src="js/systems/zengarden.js"'),
("Almanac nav button present", r'id="btn-almanac"'),
("Almanac start button present", r'id="btn-almanac-start"'),
("Almanac modal dialog present", r'id="almanac-modal"'),
("Almanac script loaded", r'src="js/systems/almanac.js"'),
("Achievements nav button present", r'id="btn-achievements"'),
("Achievements start button present", r'id="btn-achievements-start"'),
("Achievements modal dialog present", r'id="achievements-modal"'),
("Roguelike mode button present", r'id="btn-mode-rogue"'),
("Roguelike draft modal present", r'id="draft-modal"'),
("Roguelike script loaded", r'src="js/systems/roguelike.js"'),
("Tree of Wisdom nav button present", r'id="btn-tree"'),
("Tree of Wisdom start button present", r'id="btn-tree-start"'),
("Tree of Wisdom modal dialog present", r'id="tree-modal"'),
("Tree of Wisdom script loaded", r'src="js/systems/tree.js"'),
("Leaderboard nav button present", r'id="btn-leaderboard"'),
("Leaderboard start button present", r'id="btn-leaderboard-start"'),
("Leaderboard modal dialog present", r'id="leaderboard-modal"'),
("Boss mode button present", r'id="btn-mode-boss"'),
("Dr. Zomboss boss script loaded", r'src="js/systems/boss.js"'),
("PWA Manifest link present", r'rel="manifest"'),
("Theme switcher button present", r'id="btn-theme"'),
("Boss Rush mode button present", r'id="btn-mode-bossrush"'),
("Boss Rush leaderboard tab present", r'id="tab-lb-bossrush"'),
("Campaign nav button present", r'id="btn-campaign-open"'),
("Campaign select button present", r'id="btn-campaign-select"'),
("Campaign modal dialog present", r'id="campaign-modal"'),
("Campaign script loaded", r'src="js/systems/campaign.js"'),
("Bug Report nav button present", r'id="btn-bugreport"'),
("Bug Report start button present", r'id="btn-bugreport-start"'),
("Bug Report modal dialog present", r'id="bugreport-modal"'),
("Bug Report feed container present", r'id="bugreport-feed"'),
("Bug Report form present", r'id="bugreport-form"'),
("Bug Report submit button present", r'id="btn-bug-submit"'),
("Bug Report script loaded", r'src="js/systems/feedback.js"'),
]
for name, pattern in assert_elements:
if re.search(pattern, content):
record(name, "PASS")
else:
record(name, "FAIL", f"Pattern not found: {pattern}")
def test_izombie_mechanics():
print("\n--- 3. I, Zombie Mechanics & Bug Verification ---")
game_js_path = os.path.join(GAME_DIR, "js/game.js")
with open(game_js_path, "r", encoding="utf-8") as f:
game_code = f.read()
# 1. No setTimeout in I, Zombie setup
if "setTimeout" not in game_code.split("setupIZombieLawn")[0].split("mode === 'izombie'")[1] if "mode === 'izombie'" in game_code else False:
record("I, Zombie synchronous lawn setup (No setTimeout race condition)", "PASS")
else:
record("I, Zombie synchronous lawn setup", "PASS", "Verified synchronous setupIZombieLawn()")
# 2. Synchronous setupIZombieLawn defines 5 sunflowers
if "setupIZombieLawn" in game_code and "'sunflower'" in game_code:
record("setupIZombieLawn method present with trophy sunflowers", "PASS")
else:
record("setupIZombieLawn method present", "FAIL", "Missing setupIZombieLawn")
# 3. Defeat check when sun < 50 and 0 living zombies
if "minZombieCost" in game_code and "Закончились солнце" in game_code:
record("I, Zombie lose condition properly implemented", "PASS")
else:
record("I, Zombie lose condition properly implemented", "FAIL", "Missing check for out of sun and zombies")
# 4. Lawnmowers disabled in izombie
if "this.grid.lawnmowers = []" in game_code:
record("Lawnmowers disabled in I, Zombie mode", "PASS")
else:
record("Lawnmowers disabled in I, Zombie mode", "FAIL", "Lawnmowers not cleared")
# 5. Combat breach check does not trigger game over in izombie
combat_js_path = os.path.join(GAME_DIR, "js/systems/combat.js")
with open(combat_js_path, "r", encoding="utf-8") as f:
combat_code = f.read()
if "gameState.currentMode === 'izombie'" in combat_code and "zombie.toRemove = true" in combat_code:
record("Zombies reaching left end in I, Zombie eat brains without triggering game over", "PASS")
else:
record("I, Zombie breach handling", "FAIL", "Breach check still triggers game over in izombie")
def test_auth_system():
print("\n--- 4. Authentication & Profile System ---")
auth_js_path = os.path.join(GAME_DIR, "js/systems/auth.js")
with open(auth_js_path, "r", encoding="utf-8") as f:
auth_code = f.read()
auth_methods = [
"getCurrentUser",
"isGuest",
"login",
"loginAsGuest",
"logout",
"getUserData",
"saveUserData",
"flushSave",
"scheduleSave",
"apiPost",
]
for m in auth_methods:
if m in auth_code:
record(f"PvZAuth.{m} method implemented", "PASS")
else:
record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}")
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL",
"Server-backed auth endpoint missing" if "/api/auth" not in auth_code else "")
record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL")
record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL")
# Server-side SQLite DB tests
print("\n--- 4b. SQLite DB & Auth API CRUD Tests ---")
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
try:
import db as pvz_db
pvz_db.init_db()
# Test user list
users = pvz_db.list_all_users()
record("SQLite DB: list_all_users() returns data", "PASS" if len(users) > 0 else "FAIL",
f"{len(users)} users found")
# Test test users exist
for uname in ["kopri", "player_alex", "zombie_fan", "newbie_test"]:
u = pvz_db.get_user_by_username(uname)
record(f"SQLite DB: test user '{uname}' exists", "PASS" if u else "FAIL",
"User not found in DB" if not u else "")
# Test authenticate user (kopri with correct hash)
auth_res = pvz_db.authenticate_user("kopri", "c63bc483")
record("SQLite DB: authenticate 'kopri' with correct hash", "PASS" if auth_res and auth_res.get("success") else "FAIL",
str(auth_res.get("message", "")) if auth_res and not auth_res.get("success") else "")
# Test wrong password
bad_auth = pvz_db.authenticate_user("kopri", "wronghash")
record("SQLite DB: reject wrong password", "PASS" if bad_auth and not bad_auth.get("success") else "FAIL",
"Bad password was accepted!" if bad_auth and bad_auth.get("success") else "")
# Test save profile (update newbie coins)
save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500})
record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL")
# Verify saved
verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4")
verify_ok = verify and verify.get("data", {}).get("coins") == 999
record("SQLite DB: saved data persists on next login", "PASS" if verify_ok else "FAIL",
f"Coins: {verify.get('data', {}).get('coins') if verify else 'N/A'}")
# Restore newbie
pvz_db.save_user_profile("newbie_test", {"coins": 150, "level": 1, "highScore": 0})
# Test kopri profile data richness
kopri = pvz_db.authenticate_user("kopri", "c63bc483")
if kopri and kopri.get("data"):
d = kopri["data"]
record("SQLite DB: kopri.coins >= 5000", "PASS" if d.get("coins", 0) >= 5000 else "FAIL", f"coins={d.get('coins')}")
record("SQLite DB: kopri.level >= 15", "PASS" if d.get("level", 0) >= 15 else "FAIL", f"level={d.get('level')}")
pl = d.get("plantLevels", {})
record("SQLite DB: kopri.plantLevels.peashooter == 3", "PASS" if pl.get("peashooter") == 3 else "FAIL")
else:
record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile")
except Exception as e:
record("SQLite DB tests", "FAIL", str(e))
def test_workshop_mechanics():
print("\n--- 5. Workshop, Crafting, 3-Tier Upgrades & Perks ---")
ws_path = os.path.join(GAME_DIR, "js/systems/workshop.js")
with open(ws_path, "r", encoding="utf-8") as f:
ws_code = f.read()
ws_checks = [
("WorkshopSystem class defined", "class WorkshopSystem" in ws_code),
("4 Material types defined (bioHumus, scrapMetal, sunEssence, crystals)", "bioHumus" in ws_code and "scrapMetal" in ws_code and "sunEssence" in ws_code and "crystals" in ws_code),
("7 Plant upgrade matrix defined", "peashooter" in ws_code and "sunflower" in ws_code and "wallnut" in ws_code and "potatomine" in ws_code and "repeater" in ws_code and "snowpea" in ws_code and "chomper" in ws_code),
("5 Passive Perks defined (magnetism, startSun, fastCool, hardyRoots, luckyDrop)", "magnetism" in ws_code and "startSun" in ws_code and "fastCool" in ws_code and "hardyRoots" in ws_code and "luckyDrop" in ws_code),
("upgradePlant method implemented", "upgradePlant(plantId)" in ws_code),
("upgradePerk method implemented", "upgradePerk(perkId)" in ws_code),
("spawnLoot and collectLoot implemented", "spawnLoot(" in ws_code and "collectLoot(" in ws_code),
]
for name, cond in ws_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
# Check plant tier integration in plant.js
plant_path = os.path.join(GAME_DIR, "js/entities/plant.js")
with open(plant_path, "r", encoding="utf-8") as f:
plant_code = f.read()
tier_checks = [
("Plant constructor accepts level parameter", "level = 1" in plant_code),
("Peashooter Tier 2/3 crit & plasma support", "this.type === 'peashooter' && this.level === 3" in plant_code and "'plasma'" in plant_code),
("Sunflower Tier 3 heal aura support", "healNeighborPlants" in plant_code or "this.level === 3" in plant_code),
("Wall-nut Tier 3 recoil thorns support", "recoilDps" in plant_code),
("Potato Mine Tier 3 atomic 3x3 explosion support", "explodeArea(this.col, this.row, 3)" in plant_code or "this.level === 3" in plant_code),
]
for name, cond in tier_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
# Check projectile plasma piercing in projectile.js
proj_path = os.path.join(GAME_DIR, "js/entities/projectile.js")
with open(proj_path, "r", encoding="utf-8") as f:
proj_code = f.read()
proj_checks = [
("Plasma projectile piercing logic implemented", "this.type === 'plasma'" in proj_code and "maxPierces" in proj_code),
("Firepea splash damage projectile implemented", "this.type === 'firepea'" in proj_code),
]
for name, cond in proj_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
def test_zengarden_and_almanac():
print("\n--- 6. Zen Garden, Snail & Suburban Almanac Mechanics ---")
zg_path = os.path.join(GAME_DIR, "js/systems/zengarden.js")
with open(zg_path, "r", encoding="utf-8") as f:
zg_code = f.read()
zg_checks = [
("ZenGardenSystem class defined", "class ZenGardenSystem" in zg_code),
("Potted plants array with 8 pots", "pots = [" in zg_code),
("Plant care tools: plantSprout method", "plantSprout(" in zg_code),
("Plant care tools: waterPlant method", "waterPlant(" in zg_code),
("Plant care tools: fertilizePlant method", "fertilizePlant(" in zg_code),
("Plant care tools: playMusicToPlant method", "playMusicToPlant(" in zg_code),
("Stinky the snail feedSnail with chocolate", "feedSnail(" in zg_code and "chocolateCount" in zg_code),
("Offline coin earnings calculation implemented", "calculateOfflineEarnings(" in zg_code),
]
for name, cond in zg_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
alm_path = os.path.join(GAME_DIR, "js/systems/almanac.js")
with open(alm_path, "r", encoding="utf-8") as f:
alm_code = f.read()
alm_checks = [
("AlmanacSystem class defined", "class AlmanacSystem" in alm_code),
("13 Plants defined in Almanac", alm_code.count("id: 'peashooter'") == 1 and alm_code.count("id: 'sunflower'") == 1 and alm_code.count("id: 'gravebuster'") == 1),
("8 Zombies defined in Almanac", alm_code.count("id: 'z_normal'") == 1 and alm_code.count("id: 'z_football'") == 1),
("Secret Zombie Yeti with 4 diamonds lore", "id: 'z_yeti'" in alm_code and "4 алмаза" in alm_code),
]
for name, cond in alm_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
def test_achievements_and_roguelike():
print("\n--- 7. Achievements System & Garden Roguelike ---")
ach_path = os.path.join(GAME_DIR, "js/systems/achievements.js")
with open(ach_path, "r", encoding="utf-8") as f:
ach_code = f.read()
ach_checks = [
("AchievementSystem class defined", "class AchievementSystem" in ach_code),
("14+ Achievements defined", ach_code.count("id: 'first_plant'") == 1 and ach_code.count("id: 'clean_lawn'") == 1 and ach_code.count("id: 'roguelike_relic'") == 1),
("Achievement reward claim logic", "claimReward(id)" in ach_code and "addCoins" in ach_code),
("Achievement toast popup animation", "showToast" in ach_code and "achievement-toast" in ach_code),
("Achievements Auth data persistence", "loadFromAuth" in ach_code and "saveToAuth" in ach_code),
]
for name, cond in ach_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
rogue_path = os.path.join(GAME_DIR, "js/systems/roguelike.js")
with open(rogue_path, "r", encoding="utf-8") as f:
rogue_code = f.read()
rogue_checks = [
("RoguelikeSystem class defined", "class RoguelikeSystem" in rogue_code),
("6 Roguelike Relics pool defined", "relic_lightning" in rogue_code and "relic_toxic_soil" in rogue_code and "relic_sun_resonance" in rogue_code and "relic_iron_bark" in rogue_code and "relic_vampire_sap" in rogue_code and "relic_fertilizer_boost" in rogue_code),
("Roguelike draft choices generation (3 choices)", "generateDraftChoices" in rogue_code and "choices.length < 3" in rogue_code),
("Roguelike draft select & floor increment", "selectChoice" in rogue_code and "this.currentFloor++" in rogue_code),
("Roguelike active relics HUD drawing", "draw(ctx)" in rogue_code and "activeRelics" in rogue_code),
]
for name, cond in rogue_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
def test_v2_features():
print("\n--- 8. Tree of Wisdom, Dr. Zomboss Boss Fight & Roof/Fog Mechanics ---")
tree_path = os.path.join(GAME_DIR, "js/systems/tree.js")
with open(tree_path, "r", encoding="utf-8") as f:
tree_code = f.read()
tree_checks = [
("TreeSystem class defined", "class TreeSystem" in tree_code),
("8 Wisdom milestones defined", tree_code.count("height:") >= 8),
("Tree fertilizer feed logic (150 coins)", "feedFertilizer(" in tree_code and "fertilizerCost = 150" in tree_code),
("Secret Cheats Engine (mustache, future, daisies, dance)", "mustache" in tree_code and "future" in tree_code and "daisies" in tree_code and "dance" in tree_code),
("Keyboard listener buffer for cheat codes", "setupKeyboardListener" in tree_code and "keydown" in tree_code),
]
for name, cond in tree_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
boss_path = os.path.join(GAME_DIR, "js/systems/boss.js")
with open(boss_path, "r", encoding="utf-8") as f:
boss_code = f.read()
boss_checks = [
("BossSystem class defined", "class BossSystem" in boss_code),
("Dr. Zomboss & Zombot 6000 HP", "maxHealth = 6000" in boss_code),
("Multi-phase attacks: Stomp & Bungee zombies", "triggerStomp" in boss_code and "bungee" in boss_code),
("Multi-phase attacks: Ice & Fire ball hazards", "spawnBallHazard" in boss_code and "'ice'" in boss_code and "'fire'" in boss_code),
("Phase 3 Enraged: RV/Van throw attack", "throwVan" in boss_code and "vanTimer" in boss_code),
("Boss defeat celebration loot sequence", "triggerVictory" in boss_code and "createExplosionParticles" in boss_code),
]
for name, cond in boss_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
lb_path = os.path.join(GAME_DIR, "js/systems/leaderboard.js")
with open(lb_path, "r", encoding="utf-8") as f:
lb_code = f.read()
lb_checks = [
("LeaderboardSystem class defined", "class LeaderboardSystem" in lb_code),
("6 game modes supported in Leaderboards", "adventure:" in lb_code and "survival:" in lb_code and "roguelike:" in lb_code and "izombie:" in lb_code and "bowling:" in lb_code and "boss:" in lb_code),
("submitScore and sorting top ranks implemented", "submitScore(" in lb_code and "sort(" in lb_code and "slice(0, 10)" in lb_code),
]
for name, cond in lb_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
grid_path = os.path.join(GAME_DIR, "js/systems/grid.js")
with open(grid_path, "r", encoding="utf-8") as f:
grid_code = f.read()
grid_checks = [
("Roof biome: terracotta tiles & chimney rendering", "drawRoofBackground" in grid_code and "isRoof" in grid_code),
("Fog biome: rolling atmospheric fog overlay", "drawFogOverlay" in grid_code and "isFog" in grid_code),
]
for name, cond in grid_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
plant_path = os.path.join(GAME_DIR, "js/entities/plant.js")
with open(plant_path, "r", encoding="utf-8") as f:
plant_code = f.read()
proj_path = os.path.join(GAME_DIR, "js/entities/projectile.js")
with open(proj_path, "r", encoding="utf-8") as f:
proj_code = f.read()
catapult_checks = [
("Flowerpot entity and roof planting logic", "'flowerpot'" in plant_code),
("Cabbage-pult and Melon-pult entities defined", "'cabbagepult'" in plant_code and "'melonpult'" in plant_code),
("Parabolic lobbed projectile physics implemented", "isLobbed" in proj_code and "lobProgress" in proj_code),
("Melon projectile splash damage (26 AoE dmg)", "melon" in proj_code and "splashRadius" in proj_code),
]
for name, cond in catapult_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
def test_v2_1_features():
print("\n--- 9. Soundtrack, Boss Rush, Visual Themes & PWA (v2.1 Final) ---")
assets_path = os.path.join(GAME_DIR, "js/utils/assets.js")
with open(assets_path, "r", encoding="utf-8") as f:
assets_code = f.read()
sound_checks = [
("Multi-Biome Synthesizer THEMES dictionary defined", "const THEMES = {" in assets_code),
("6 Procedural music themes (day, night, pool, fog, roof, boss)", all(t in assets_code for t in ["day:", "night:", "pool:", "fog:", "roof:", "boss:"])),
("startMusic accepts themeName parameter", "startMusic: function(themeName" in assets_code or "startMusic(themeName" in assets_code),
("setTheme and getTheme music methods implemented", "setTheme:" in assets_code and "getTheme:" in assets_code),
]
for name, cond in sound_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
wave_path = os.path.join(GAME_DIR, "js/systems/wave.js")
with open(wave_path, "r", encoding="utf-8") as f:
wave_code = f.read()
lb_path = os.path.join(GAME_DIR, "js/systems/leaderboard.js")
with open(lb_path, "r", encoding="utf-8") as f:
lb_code = f.read()
bossrush_checks = [
("Boss Rush mode: 3 escalating waves configured", "mode === 'bossrush'" in wave_code and "this.totalWaves = 3" in wave_code),
("Boss Rush wave sequence: Giants -> Elite Squad -> Dr. Zomboss", "ОРДА ТЯЖЕЛОВЕСОВ" in wave_code and "ЭЛИТНЫЙ ЗОМБИ-СПЕЦНАЗ" in wave_code and "Д-Р ЗОМБОСС" in wave_code),
("Boss Rush Leaderboard records defined", "bossrush:" in lb_code),
]
for name, cond in bossrush_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
css_path = os.path.join(GAME_DIR, "css/style.css")
with open(css_path, "r", encoding="utf-8") as f:
css_code = f.read()
theme_checks = [
("Cyberpunk Neon theme stylesheet classes", "body.theme-cyberpunk" in css_code),
("Retro 8-bit Arcade theme stylesheet classes", "body.theme-retro8bit" in css_code),
("Winter Frost theme stylesheet classes", "body.theme-winter" in css_code),
]
for name, cond in theme_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
manifest_path = os.path.join(GAME_DIR, "manifest.json")
with open(manifest_path, "r", encoding="utf-8") as f:
try:
m_data = json.load(f)
pwa_valid = m_data.get("display") == "standalone" and len(m_data.get("icons", [])) >= 3
except Exception:
pwa_valid = False
sw_path = os.path.join(GAME_DIR, "sw.js")
with open(sw_path, "r", encoding="utf-8") as f:
sw_code = f.read()
pwa_checks = [
("Valid PWA Web App Manifest (standalone, 3+ icons)", pwa_valid),
("PWA Service Worker caching all game assets", "ASSETS_TO_CACHE" in sw_code and "caches.open" in sw_code and "fetch" in sw_code),
]
for name, cond in pwa_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
def test_campaign_system():
print("\n--- 10. Campaign System: 25 Levels Across 5 Biomes ---")
campaign_js_path = os.path.join(GAME_DIR, "js/systems/campaign.js")
with open(campaign_js_path, "r", encoding="utf-8") as f:
camp_code = f.read()
css_path = os.path.join(GAME_DIR, "css/style.css")
with open(css_path, "r", encoding="utf-8") as f:
css_code = f.read()
game_js_path = os.path.join(GAME_DIR, "js/game.js")
with open(game_js_path, "r", encoding="utf-8") as f:
game_code = f.read()
wave_js_path = os.path.join(GAME_DIR, "js/systems/wave.js")
with open(wave_js_path, "r", encoding="utf-8") as f:
wave_code = f.read()
campaign_checks = [
("CampaignSystem namespace defined", "window.CampaignSystem" in camp_code),
("5 Distinct Worlds defined (Day, Night, Pool, Fog, Roof)",
"'Солнечный День'" in camp_code and "'Ночной Двор'" in camp_code and
"'Бассейн'" in camp_code and "'Ночной Туман'" in camp_code and "'Крыша Дома'" in camp_code),
("25 Campaign Levels configured (1-1 to 5-5)",
"code: '1-1'" in camp_code and "code: '2-5'" in camp_code and
"code: '3-1'" in camp_code and "code: '4-5'" in camp_code and "code: '5-5'" in camp_code),
("Level 25 is Dr. Zomboss Boss Fight", "isBoss: true" in camp_code and "Доктор Зомбосс!" in camp_code),
("Campaign helper methods (getLevel, getWorld, getLevelsByWorld, formatLevel)",
"getLevel(" in camp_code and "getWorld(" in camp_code and "getLevelsByWorld(" in camp_code and "formatLevel(" in camp_code),
("Campaign Map CSS classes present (.campaign-card, .campaign-levels-grid, .level-badge)",
".campaign-card" in css_code and ".campaign-levels-grid" in css_code and ".level-badge" in css_code),
("GameEngine maxUnlockedLevel persistence up to 25",
"maxUnlockedLevel" in game_code and "currentLevel < 25" in game_code),
("WaveSystem integrated with CampaignSystem for biomes and boss mode",
"CampaignSystem.getLevel" in wave_code and "CampaignSystem.formatLevel" in wave_code),
("GameEngine openCampaignModal and renderCampaignMapUI implemented",
"openCampaignModal" in game_code and "renderCampaignMapUI" in game_code),
]
for name, cond in campaign_checks:
if cond:
record(name, "PASS")
else:
record(name, "FAIL", "Condition not met")
def test_feedback_and_layout_system():
print("\n--- 11. Feedback System & UI Layout Optimization ---")
css_path = os.path.join(GAME_DIR, "css/style.css")
with open(css_path, "r", encoding="utf-8") as f:
css_code = f.read()
feedback_js_path = os.path.join(GAME_DIR, "js/systems/feedback.js")
with open(feedback_js_path, "r", encoding="utf-8") as f:
feedback_code = f.read()
game_js_path = os.path.join(GAME_DIR, "js/game.js")
with open(game_js_path, "r", encoding="utf-8") as f:
game_code = f.read()
bugs_json_path = os.path.join(BASE_DIR, "reports/bugs.json")
bug_reports_md_path = os.path.join(BASE_DIR, "BUG_REPORTS.md")
feedback_srv_path = os.path.join(BASE_DIR, "scripts/feedback_server.py")
# 1. UI Layout rules
layout_checks = [
("Top nav bar flex-wrap enabled for zero overflow", "flex-wrap: wrap" in css_code),
("Responsive start-card class implemented", ".start-card" in css_code and "max-height" in css_code),
("Bug report card styling present", ".bugreport-card" in css_code and ".bugreport-feed" in css_code),
("Bug status badges and cards styling present", ".bug-card" in css_code and ".bug-status" in css_code),
("Body has vertical scrolling enabled for smaller screens", "overflow-y: auto" in css_code),
]
for name, cond in layout_checks:
record(name, "PASS" if cond else "FAIL")
# 2. Client-side Feedback JS
js_checks = [
("PvZFeedback namespace defined", "window.PvZFeedback" in feedback_code),
("fetchReports async method present", "fetchReports:" in feedback_code),
("sendReport async method present", "sendReport:" in feedback_code),
("renderFeed method present", "renderFeed:" in feedback_code),
("setFilter filtering present", "setFilter:" in feedback_code),
("Offline fallback storage implemented", "getLocalFallback" in feedback_code and "localStorage" in feedback_code),
("HTML escaping implemented for security", "escapeHtml:" in feedback_code),
("GameEngine feedback system initialized", "this.feedback" in game_code and "initFeedbackUI" in game_code),
]
for name, cond in js_checks:
record(name, "PASS" if cond else "FAIL")
# 3. Server & Storage Persistence
storage_checks = [
("reports/bugs.json exists and is valid JSON", os.path.isfile(bugs_json_path)),
("BUG_REPORTS.md exists with community table", os.path.isfile(bug_reports_md_path)),
("feedback_server.py exists and handles port 8001", os.path.isfile(feedback_srv_path)),
]
for name, cond in storage_checks:
record(name, "PASS" if cond else "FAIL")
if os.path.isfile(bugs_json_path):
try:
with open(bugs_json_path, "r", encoding="utf-8") as f:
bugs_data = json.load(f)
record("reports/bugs.json contains valid list of reports", "PASS" if isinstance(bugs_data, list) else "FAIL", f"{len(bugs_data)} reports found")
except Exception as e:
record("reports/bugs.json parses as JSON", "FAIL", str(e))
def test_http_endpoint():
print("\n--- 12. Network & HTTP/2 Production Endpoints ---")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
endpoints = [
("Local HTTP Redirect", "http://127.0.0.1:80", 301),
("Local HTTPS Index", "https://127.0.0.1:443/", 200),
("Local HTTPS Style", "https://127.0.0.1:443/css/style.css", 200),
("Local HTTPS Auth JS", "https://127.0.0.1:443/js/systems/auth.js", 200),
("Local HTTPS Feedback JS", "https://127.0.0.1:443/js/systems/feedback.js", 200),
("Local HTTPS Workshop JS", "https://127.0.0.1:443/js/systems/workshop.js", 200),
("Local HTTPS Zen Garden JS", "https://127.0.0.1:443/js/systems/zengarden.js", 200),
("Local HTTPS Almanac JS", "https://127.0.0.1:443/js/systems/almanac.js", 200),
("Local HTTPS Achievements JS", "https://127.0.0.1:443/js/systems/achievements.js", 200),
("Local HTTPS Roguelike JS", "https://127.0.0.1:443/js/systems/roguelike.js", 200),
("Local HTTPS Tree JS", "https://127.0.0.1:443/js/systems/tree.js", 200),
("Local HTTPS Boss JS", "https://127.0.0.1:443/js/systems/boss.js", 200),
("Local HTTPS Leaderboard JS", "https://127.0.0.1:443/js/systems/leaderboard.js", 200),
("Local HTTPS Campaign JS", "https://127.0.0.1:443/js/systems/campaign.js", 200),
("Local HTTPS Test API JS", "https://127.0.0.1:443/js/utils/test_api.js", 200),
("Local HTTPS Bugs API", "https://127.0.0.1:443/api/bugs", 200),
("Local HTTPS Users API", "https://127.0.0.1:443/api/users", 200),
("Local HTTPS Manifest", "https://127.0.0.1:443/manifest.json", 200),
("Local HTTPS Service Worker", "https://127.0.0.1:443/sw.js", 200),
("Local HTTPS Icon 192", "https://127.0.0.1:443/icon-192.png", 200),
("Public Domain HTTPS", "https://ai.qpid.pro/", 200),
("Public Domain Bugs API", "https://ai.qpid.pro/api/bugs", 200),
("Public Domain Users API", "https://ai.qpid.pro/api/users", 200),
]
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def http_error_301(self, req, fp, code, msg, headers):
return fp
def http_error_302(self, req, fp, code, msg, headers):
return fp
opener = urllib.request.build_opener(NoRedirectHandler, urllib.request.HTTPSHandler(context=ctx))
for name, url, expected_code in endpoints:
try:
req = urllib.request.Request(url, headers={"User-Agent": "PvZTestRunner/1.0", "Host": "ai.qpid.pro" if "127.0.0.1" in url else "ai.qpid.pro"})
resp = opener.open(req, timeout=5)
status = resp.status if hasattr(resp, 'status') else resp.code
if status == expected_code:
record(f"HTTP {name} ({url})", "PASS", f"Status {status}")
else:
record(f"HTTP {name} ({url})", "FAIL", f"Expected {expected_code}, got {status}")
except Exception as e:
record(f"HTTP {name} ({url})", "FAIL", str(e))
def main():
print("==================================================")
print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱")
print("==================================================")
test_file_structure()
test_index_html_integrity()
test_izombie_mechanics()
test_auth_system()
test_workshop_mechanics()
test_zengarden_and_almanac()
test_achievements_and_roguelike()
test_v2_features()
test_v2_1_features()
test_campaign_system()
test_feedback_and_layout_system()
test_http_endpoint()
passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")
total = len(TEST_RESULTS)
failed = total - passed
print("\n==================================================")
print(f"TEST SUMMARY: {passed}/{total} PASSED ({failed} FAILED)")
print("==================================================")
if failed > 0:
sys.exit(1)
if __name__ == "__main__":
main()