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
+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');
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user