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