From 23918dab1fe5b7264eabd6b09be529f588b4ca7f Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 17 Sep 2026 13:20:29 +0000 Subject: [PATCH] feat(qol & backup): add wave progress bar, hover collection, area explosion preview, hotkeys, auto-sync and json save export/import --- game/index.html | 21 ++++ game/js/game.js | 97 +++++++++++++++- game/js/systems/auth.js | 227 ++++++++++++++++++++++++++++++++++--- game/js/systems/economy.js | 22 ++++ game/js/systems/grid.js | 141 ++++++++++++++++++++++- game/js/systems/wave.js | 202 ++++++++++++++++++++++++++++++--- scripts/test_game.py | 38 +++++++ 7 files changed, 710 insertions(+), 38 deletions(-) diff --git a/game/index.html b/game/index.html index fddbad7..b9dc9d7 100644 --- a/game/index.html +++ b/game/index.html @@ -445,6 +445,15 @@ + +
+
💾 Резервная копия сохранений:
+
+ + +
+
+
@@ -472,6 +481,15 @@
+ +
+
💾 Локальные сохранения (без аккаунта):
+
+ + +
+
+ @@ -495,6 +513,9 @@ + + + diff --git a/game/js/game.js b/game/js/game.js index 58e3586..c4bec7d 100644 --- a/game/js/game.js +++ b/game/js/game.js @@ -677,12 +677,20 @@ window.GameEngine = class GameEngine { } checkTouchCollect(pos) { + if (!this.isRunning) return; for (let i = this.economy.suns.length - 1; i >= 0; i--) { const s = this.economy.suns[i]; if (!s.collecting && Math.hypot(pos.x - s.x, pos.y - s.y) <= s.radius + 28) { s.collecting = true; this.triggerHaptic(12); if (window.PvZAudio) window.PvZAudio.playSun(); + if (this.economy.workshop) { + this.economy.sunCount = (this.economy.sunCount || 0) + 1; + if (this.economy.sunCount >= 8) { + this.economy.sunCount = 0; + this.economy.workshop.addMaterial('sunEssence', 1); + } + } } } for (let i = this.economy.coins.length - 1; i >= 0; i--) { @@ -699,6 +707,9 @@ window.GameEngine = class GameEngine { this.canvas.addEventListener('mousemove', (e) => { const pos = this.getEventPos(e); this.hoverCell = this.grid.getCellFromCoords(pos.x, pos.y); + if (this.isRunning) { + this.checkTouchCollect(pos); + } }); this.canvas.addEventListener('mouseleave', () => { @@ -920,10 +931,11 @@ window.GameEngine = class GameEngine { document.addEventListener('fullscreenchange', updateFullscreenBtn); document.addEventListener('webkitfullscreenchange', updateFullscreenBtn); - // Global Hotkeys (Space, KeyP for Pause; KeyF for Fullscreen) + // Global Hotkeys (Space/KeyP: Pause; KeyF: Fullscreen; Digit1-Digit6: Deck slots 1-6; KeyS: Shovel) window.addEventListener('keydown', (e) => { // Do not intercept hotkeys if user is typing in an input or textarea if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return; + if (e.code === 'Space' || e.code === 'KeyP') { e.preventDefault(); if (this.isRunning) { @@ -932,6 +944,29 @@ window.GameEngine = class GameEngine { } else if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); + } else if (e.code === 'KeyS' || e.key === 's' || e.key === 'S' || e.key === 'ы' || e.key === 'Ы') { + e.preventDefault(); + if (this.isRunning && !this.isPaused) { + this.economy.selectedCard = (this.economy.selectedCard === 'shovel') ? null : 'shovel'; + if (window.PvZAudio) window.PvZAudio.playButtonClick(); + this.triggerHaptic(15); + } + } else if (['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4', 'Numpad5', 'Numpad6'].includes(e.code) || (e.key >= '1' && e.key <= '6')) { + const num = parseInt(e.key, 10) || parseInt(e.code.replace(/\D/g, ''), 10); + const slotIdx = num - 1; + if (this.isRunning && !this.isPaused && this.economy.cards && slotIdx >= 0 && slotIdx < this.economy.cards.length) { + e.preventDefault(); + const card = this.economy.cards[slotIdx]; + if (card) { + if (this.economy.selectedCard === card.id) { + this.economy.selectedCard = null; + } else if (this.economy.canAfford(card.cost) && card.timer <= 0) { + this.economy.selectedCard = card.id; + if (window.PvZAudio) window.PvZAudio.playButtonClick(); + this.triggerHaptic(15); + } + } + } } }); @@ -1182,6 +1217,66 @@ window.GameEngine = class GameEngine { }); } + // Export / Import Save File Handlers + const btnExportSave = document.getElementById('btn-export-save'); + const btnExportSaveProfile = document.getElementById('btn-export-save-profile'); + const btnImportSave = document.getElementById('btn-import-save'); + const btnImportSaveProfile = document.getElementById('btn-import-save-profile'); + const inputImportSave = document.getElementById('input-import-save'); + + const handleExportSave = () => { + this.saveUserData(); + if (window.PvZAuth && window.PvZAuth.exportSaveFile) { + window.PvZAuth.exportSaveFile(); + } + }; + + if (btnExportSave) btnExportSave.addEventListener('click', handleExportSave); + if (btnExportSaveProfile) btnExportSaveProfile.addEventListener('click', handleExportSave); + + const handleTriggerImport = () => { + if (inputImportSave) { + inputImportSave.value = ''; + inputImportSave.click(); + } + }; + + if (btnImportSave) btnImportSave.addEventListener('click', handleTriggerImport); + if (btnImportSaveProfile) btnImportSaveProfile.addEventListener('click', handleTriggerImport); + + if (inputImportSave) { + inputImportSave.addEventListener('change', async (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + if (window.PvZAuth && window.PvZAuth.importSaveFile) { + const res = await window.PvZAuth.importSaveFile(file); + if (res && res.success) { + this.loadUserData(); + this.updateProfileUI(); + this.updateRecordDisplay(); + this.updateShopUI(); + } + } + inputImportSave.value = ''; + }); + } + + // Auto-save on page hide / tab switch / page unload + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.saveUserData(); + if (window.PvZAuth && window.PvZAuth.flushSave) { + window.PvZAuth.flushSave(); + } + } + }); + + window.addEventListener('beforeunload', () => { + this.saveUserData(); + if (window.PvZAuth && window.PvZAuth.flushSave) { + window.PvZAuth.flushSave(true); + } + }); // Inventory / Deck Builder Buttons const btnInvStart = document.getElementById('btn-inventory-start'); diff --git a/game/js/systems/auth.js b/game/js/systems/auth.js index 7065f24..a9f9826 100644 --- a/game/js/systems/auth.js +++ b/game/js/systems/auth.js @@ -56,6 +56,30 @@ window.PvZAuth = (function () { return { valid: true, password: p }; } + /* ─── Notification Toast Helper ─── */ + function showNotification(title, subtitle = '', icon = '💾') { + try { + 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 = ` +
${icon}
+
+
${subtitle || 'УВЕДОМЛЕНИЕ'}
+
${title}
+
+ `; + toast.classList.add('visible'); + setTimeout(() => { + if (toast) toast.classList.remove('visible'); + }, 3500); + } catch (_) {} + } + /* ─── Default blank profile ─── */ function getDefaultData() { return { @@ -101,7 +125,7 @@ window.PvZAuth = (function () { } /* ─── Non-blocking server call with Bearer Token Authorization ─── */ - async function apiPost(endpoint, body = {}) { + async function apiPost(endpoint, body = {}, keepalive = false) { try { const headers = { 'Content-Type': 'application/json' }; const token = getToken(); @@ -114,11 +138,16 @@ window.PvZAuth = (function () { ? { token, ...body } : body; - const resp = await fetch(`${API_BASE}/${endpoint}`, { + const fetchOptions = { method: 'POST', headers, body: JSON.stringify(payload) - }); + }; + if (keepalive && typeof fetch === 'function') { + fetchOptions.keepalive = true; + } + + const resp = await fetch(`${API_BASE}/${endpoint}`, fetchOptions); if (resp.status === 401) { // Clear invalid or expired session token @@ -170,7 +199,7 @@ window.PvZAuth = (function () { }; } - return { + const authInstance = { /* ── Public API ── */ getCurrentUser() { @@ -190,6 +219,7 @@ window.PvZAuth = (function () { validatePassword, apiPost, scheduleSave, + showNotification, /** * User Login @@ -250,7 +280,7 @@ window.PvZAuth = (function () { const cleanUser = userVal.username; const pwdHash = hashPassword(passVal.password); - const initData = getDefaultData(); + const initData = _cachedData || getDefaultData(); const res = await apiPost('register', { username: cleanUser, passwordHash: pwdHash, initialData: initData }); @@ -277,8 +307,6 @@ window.PvZAuth = (function () { loginAsGuest() { clearToken(); localStorage.setItem(CURRENT_USER_KEY, 'Гость'); - _cachedData = null; - localSave(null); return { success: true, user: 'Гость' }; }, @@ -296,14 +324,12 @@ window.PvZAuth = (function () { }, getUserData() { - if (this.isGuest()) return getDefaultData(); - if (_cachedData) return _cachedData; const local = localLoad(); if (local) { - _cachedData = local; - return local; + _cachedData = mergeUserData(local); + return _cachedData; } _cachedData = getDefaultData(); @@ -311,21 +337,186 @@ window.PvZAuth = (function () { }, 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 + localSave(_cachedData); // instant local write for both guests and registered users + + if (!this.isGuest()) { + scheduleSave(); // debounced server sync for logged-in accounts + } }, - /** Force immediate server save (call on game-over / victory) */ - flushSave() { + /** Force immediate server save (call on game-over / victory / visibility hidden / unload) */ + flushSave(keepalive = false) { if (this.isGuest() || !_cachedData) return; if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } - apiPost('save', { username: this.getCurrentUser(), data: _cachedData }); + apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive); + }, + + /** + * Export all save data (Profile, levels, shop, garden, scores) to a JSON file + */ + exportSaveFile() { + try { + if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') { + window._pvzEngine.saveUserData(); + } + + const data = this.getUserData(); + const username = this.getCurrentUser(); + const isGuest = this.isGuest(); + + const highScores = { + adventure: data.highScore || 0, + survival: parseInt(localStorage.getItem('pvz_hs_survival') || '0', 10), + bowling: parseInt(localStorage.getItem('pvz_hs_bowling') || '0', 10), + izombie: parseInt(localStorage.getItem('pvz_hs_izombie') || '0', 10), + rogue: parseInt(localStorage.getItem('pvz_hs_rogue') || '0', 10), + boss: parseInt(localStorage.getItem('pvz_hs_boss') || '0', 10), + bossrush: parseInt(localStorage.getItem('pvz_hs_bossrush') || '0', 10) + }; + + const exportPayload = { + schema: 'pvz_save_file', + version: 2, + exportedAt: new Date().toISOString(), + username: isGuest ? 'Гость' : username, + data: { + ...data, + highScores + } + }; + + const jsonStr = JSON.stringify(exportPayload, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10); + const safeUser = (username || 'guest').replace(/[^a-zA-Zа-яА-Я0-9_-]/g, '_'); + const filename = `pvz_save_${safeUser}_${dateStr}.json`; + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + + showNotification('Файл сохранения скачан!', filename, '💾'); + if (window.PvZAudio && window.PvZAudio.playCoin) window.PvZAudio.playCoin(); + + return { success: true, filename }; + } catch (err) { + console.error('Export save error:', err); + showNotification('Ошибка экспорта', err.message || 'Сбой', '❌'); + return { success: false, message: err.message }; + } + }, + + /** + * Import save data from JSON file with validation & UI refresh + */ + async importSaveFile(file) { + if (!file) { + showNotification('Файл не выбран', 'Выберите .json файл', '⚠️'); + return { success: false, message: 'Файл не выбран' }; + } + + try { + const text = await file.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch (e) { + showNotification('Некорректный JSON', 'Файл поврежден или не является JSON', '❌'); + return { success: false, message: 'Некорректный JSON формат файла' }; + } + + if (!parsed || typeof parsed !== 'object') { + showNotification('Неверный формат', 'Файл пуст или некорректен', '❌'); + return { success: false, message: 'Неверная структура данных' }; + } + + // Support both wrapped export payload and raw data format + let rawData = (parsed.schema === 'pvz_save_file' && parsed.data) ? parsed.data : (parsed.data || parsed); + + if (typeof rawData !== 'object' || rawData === null) { + showNotification('Ошибка в данных', 'Внутри файла нет объекта сохранений', '❌'); + return { success: false, message: 'Некорректный объект данных' }; + } + + // Field sanitization & validation + if (rawData.coins !== undefined && (typeof rawData.coins !== 'number' || isNaN(rawData.coins) || rawData.coins < 0)) { + rawData.coins = 150; + } + if (rawData.level !== undefined && (typeof rawData.level !== 'number' || isNaN(rawData.level) || rawData.level < 1)) { + rawData.level = 1; + } + if (rawData.maxUnlockedLevel !== undefined && (typeof rawData.maxUnlockedLevel !== 'number' || isNaN(rawData.maxUnlockedLevel) || rawData.maxUnlockedLevel < 1)) { + rawData.maxUnlockedLevel = rawData.level || 1; + } + + // Merge with defaults to guarantee all required properties exist + _cachedData = mergeUserData(rawData); + localSave(_cachedData); + + // Restore individual high score keys if present + if (rawData.highScores && typeof rawData.highScores === 'object') { + if (rawData.highScores.survival !== undefined) localStorage.setItem('pvz_hs_survival', String(rawData.highScores.survival)); + if (rawData.highScores.bowling !== undefined) localStorage.setItem('pvz_hs_bowling', String(rawData.highScores.bowling)); + if (rawData.highScores.izombie !== undefined) localStorage.setItem('pvz_hs_izombie', String(rawData.highScores.izombie)); + if (rawData.highScores.rogue !== undefined) localStorage.setItem('pvz_hs_rogue', String(rawData.highScores.rogue)); + if (rawData.highScores.boss !== undefined) localStorage.setItem('pvz_hs_boss', String(rawData.highScores.boss)); + if (rawData.highScores.bossrush !== undefined) localStorage.setItem('pvz_hs_bossrush', String(rawData.highScores.bossrush)); + } + + // Sync to server if user is logged in + if (!this.isGuest()) { + this.flushSave(); + } + + // Reload state into game engine + if (window._pvzEngine) { + window._pvzEngine.loadUserData(); + window._pvzEngine.updateProfileUI(); + window._pvzEngine.updateRecordDisplay(); + window._pvzEngine.updateShopUI(); + } + + showNotification('Сохранение загружено!', `Баланс: ${_cachedData.coins} 💰, Уровень ${_cachedData.level}`, '✅'); + if (window.PvZAudio && window.PvZAudio.playCoin) window.PvZAudio.playCoin(); + + return { success: true, data: _cachedData }; + } catch (err) { + console.error('Import save error:', err); + showNotification('Ошибка импорта', err.message || 'Не удалось прочитать файл', '❌'); + return { success: false, message: err.message }; + } } }; + + /* ─── Auto-save on page exit / tab switch ─── */ + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') { + window._pvzEngine.saveUserData(); + } + authInstance.flushSave(); + } + }); + + window.addEventListener('beforeunload', () => { + if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') { + window._pvzEngine.saveUserData(); + } + authInstance.flushSave(true); + }); + } + + return authInstance; })(); diff --git a/game/js/systems/economy.js b/game/js/systems/economy.js index 30555a5..4391e61 100644 --- a/game/js/systems/economy.js +++ b/game/js/systems/economy.js @@ -559,6 +559,18 @@ window.EconomySystem = class EconomySystem { this.drawCardThumbnail(ctx, card.id, cx + cardW / 2, cardY + 28); + // Slot Hotkey Number Indicator (1..6) + if (i < 9) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'; + ctx.beginPath(); + ctx.roundRect(cx + 3, cardY + 3, 13, 13, 3); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText((i + 1).toString(), cx + 9.5, cardY + 13); + } + if (this.workshop) { const lvl = this.workshop.getPlantLevel(card.id); if (lvl > 1) { @@ -608,6 +620,16 @@ window.EconomySystem = class EconomySystem { ctx.lineWidth = isShovelSelected ? 3 : 2; ctx.stroke(); + // Hotkey [S] badge on shovel + ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'; + ctx.beginPath(); + ctx.roundRect(shovelX + 3, cardY + 3, 14, 13, 3); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('S', shovelX + 10, cardY + 13); + this.drawShovelGraphic(ctx, shovelX + 27, cardY + 36); ctx.fillStyle = '#37474f'; ctx.font = 'bold 9px sans-serif'; diff --git a/game/js/systems/grid.js b/game/js/systems/grid.js index 733551e..6fc3650 100644 --- a/game/js/systems/grid.js +++ b/game/js/systems/grid.js @@ -313,7 +313,12 @@ window.GridSystem = class GridSystem { this.drawGravestone(ctx, bounds.centerX, bounds.centerY); } - // 3. Hover effect + // 2.5 Area / Range Preview Highlight (Cherry Bomb 3x3, Jalapeno row, Potato Mine, Doom-shroom 5x5) + if (hoverCell && selectedPlantType) { + this.drawAreaPreview(ctx, hoverCell, selectedPlantType); + } + + // 3. Hover placement indicator if (hoverCell && selectedPlantType) { const bounds = this.getCellBounds(hoverCell.col, hoverCell.row); const isWater = this.isWaterCell(hoverCell.row); @@ -560,4 +565,138 @@ window.GridSystem = class GridSystem { ctx.restore(); } + + drawAreaPreview(ctx, hoverCell, selectedPlantType) { + if (!hoverCell || !selectedPlantType) return; + + ctx.save(); + + // 1. Cherry Bomb / Bowling Cherry: 3x3 explosion area + if (selectedPlantType === 'cherrybomb' || selectedPlantType === 'bowling_cherry') { + const minCol = Math.max(0, hoverCell.col - 1); + const maxCol = Math.min(this.cols - 1, hoverCell.col + 1); + const minRow = Math.max(0, hoverCell.row - 1); + const maxRow = Math.min(this.rows - 1, hoverCell.row + 1); + + // Fill affected tiles + for (let r = minRow; r <= maxRow; r++) { + for (let c = minCol; c <= maxCol; c++) { + const b = this.getCellBounds(c, r); + ctx.fillStyle = 'rgba(255, 87, 34, 0.28)'; + ctx.fillRect(b.x, b.y, b.w, b.h); + ctx.strokeStyle = 'rgba(255, 112, 67, 0.45)'; + ctx.lineWidth = 1; + ctx.strokeRect(b.x + 1, b.y + 1, b.w - 2, b.h - 2); + } + } + + // Enclosing blast perimeter + const tl = this.getCellBounds(minCol, minRow); + const br = this.getCellBounds(maxCol, maxRow); + const areaW = (br.x + br.w) - tl.x; + const areaH = (br.y + br.h) - tl.y; + + ctx.strokeStyle = '#ff3d00'; + ctx.lineWidth = 2.5; + ctx.setLineDash([8, 4]); + ctx.strokeRect(tl.x, tl.y, areaW, areaH); + ctx.setLineDash([]); + + ctx.fillStyle = '#ff1744'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'left'; + ctx.shadowColor = '#000000'; + ctx.shadowBlur = 4; + ctx.fillText('💥 ЗОНА ВЗРЫВА 3х3', tl.x + 6, tl.y - 4); + } + // 2. Jalapeno: Entire Row Lane + else if (selectedPlantType === 'jalapeno') { + const r = hoverCell.row; + const startX = this.startX; + const totalW = this.cols * this.cellW; + const y = this.startY + r * this.cellH; + const h = this.cellH; + + ctx.fillStyle = 'rgba(244, 67, 54, 0.28)'; + ctx.fillRect(startX, y, totalW, h); + + // Fiery boundary lines + ctx.strokeStyle = '#ff5722'; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(startX, y); + ctx.lineTo(startX + totalW, y); + ctx.moveTo(startX, y + h); + ctx.lineTo(startX + totalW, y + h); + ctx.stroke(); + + ctx.fillStyle = '#ff3d00'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'left'; + ctx.shadowColor = '#000000'; + ctx.shadowBlur = 4; + ctx.fillText('🔥 ОГНЕННАЯ ЛИНИЯ ПО ВСЕЙ ПОЛОСЕ', startX + 6, y - 4); + } + // 3. Potato Mine: Single tile armed trigger zone + else if (selectedPlantType === 'potatomine') { + const b = this.getCellBounds(hoverCell.col, hoverCell.row); + ctx.fillStyle = 'rgba(255, 193, 7, 0.25)'; + ctx.fillRect(b.x, b.y, b.w, b.h); + + ctx.strokeStyle = '#ffb300'; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.strokeRect(b.x + 3, b.y + 3, b.w - 6, b.h - 6); + ctx.setLineDash([]); + + // Crosshair / target marker in tile center + ctx.strokeStyle = 'rgba(255, 179, 0, 0.6)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(b.centerX, b.centerY, 18, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = '#ffa000'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.shadowColor = '#000000'; + ctx.shadowBlur = 3; + ctx.fillText('💣 МИНА', b.centerX, b.y - 3); + } + // 4. Doom-shroom: Large 5x5 blast area + else if (selectedPlantType === 'doomshroom') { + const minCol = Math.max(0, hoverCell.col - 2); + const maxCol = Math.min(this.cols - 1, hoverCell.col + 2); + const minRow = Math.max(0, hoverCell.row - 2); + const maxRow = Math.min(this.rows - 1, hoverCell.row + 2); + + for (let r = minRow; r <= maxRow; r++) { + for (let c = minCol; c <= maxCol; c++) { + const b = this.getCellBounds(c, r); + ctx.fillStyle = 'rgba(156, 39, 176, 0.28)'; + ctx.fillRect(b.x, b.y, b.w, b.h); + } + } + + const tl = this.getCellBounds(minCol, minRow); + const br = this.getCellBounds(maxCol, maxRow); + const areaW = (br.x + br.w) - tl.x; + const areaH = (br.y + br.h) - tl.y; + + ctx.strokeStyle = '#ab47bc'; + ctx.lineWidth = 2.5; + ctx.setLineDash([8, 4]); + ctx.strokeRect(tl.x, tl.y, areaW, areaH); + ctx.setLineDash([]); + + ctx.fillStyle = '#ba68c8'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'left'; + ctx.shadowColor = '#000000'; + ctx.shadowBlur = 4; + ctx.fillText('☢️ ЯДЕРНЫЙ ВЗРЫВ 5х5', tl.x + 6, tl.y - 4); + } + + ctx.restore(); + } }; diff --git a/game/js/systems/wave.js b/game/js/systems/wave.js index bf51377..720ba9b 100644 --- a/game/js/systems/wave.js +++ b/game/js/systems/wave.js @@ -262,6 +262,7 @@ window.WaveSystem = class WaveSystem { }, 2500); } } + this.initialWaveZombies = Math.max(1, this.spawnQueue.length); } else if (this.currentWave >= 4) { this.bannerText = '☠️ ПОСЛЕДНЯЯ ВОЛНА!'; this.bannerTimer = 4.5; @@ -280,6 +281,10 @@ window.WaveSystem = class WaveSystem { { 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) } ]; + this.initialWaveZombies = Math.max(1, this.spawnQueue.length); + } + if (!this.initialWaveZombies) { + this.initialWaveZombies = Math.max(1, this.spawnQueue.length); } } @@ -287,6 +292,21 @@ window.WaveSystem = class WaveSystem { return Math.floor(Math.random() * gameState.grid.rows); } + getFlagWaves() { + if (this.mode === 'survival' || this.mode === 'izombie') return []; + const flags = []; + if (this.totalWaves >= 4) { + flags.push({ wave: this.totalWaves - 1, type: 'huge' }); + flags.push({ wave: this.totalWaves, type: 'final' }); + } else if (this.totalWaves === 3) { + flags.push({ wave: 2, type: 'huge' }); + flags.push({ wave: 3, type: 'final' }); + } else if (this.totalWaves >= 1) { + flags.push({ wave: this.totalWaves, type: 'final' }); + } + return flags; + } + update(dt, gameState) { if (this.bannerTimer > 0) { this.bannerTimer -= dt; @@ -294,6 +314,29 @@ window.WaveSystem = class WaveSystem { if (this.mode === 'izombie') return; + // Smooth wave progress calculation + const total = Math.max(1, this.totalWaves); + let currentFrac = 0; + if (this.waveState === 'waiting' || this.waveState === 'completed') { + currentFrac = 1.0; + } else if (this.waveState === 'spawning') { + const initial = Math.max(1, this.initialWaveZombies || 1); + const remainingInQueue = this.spawnQueue.length; + const spawnedFrac = Math.max(0, 1 - (remainingInQueue / initial)); + const aliveCount = (gameState && gameState.zombies) ? gameState.zombies.length : 0; + const defeatFrac = aliveCount > 0 ? 0.5 : 1.0; + currentFrac = Math.min(0.95, spawnedFrac * 0.7 + defeatFrac * 0.25); + } else { + currentFrac = 0.05; + } + + const targetProgress = this.mode === 'survival' + ? 1.0 + : Math.min(1, Math.max(0, (Math.max(0, this.currentWave - 1) + currentFrac) / total)); + + if (this.smoothProgress === undefined) this.smoothProgress = 0; + this.smoothProgress += (targetProgress - this.smoothProgress) * Math.min(1, dt * 3.5); + if (this.waveState === 'starting') { this.waveTimer += dt; if (this.waveTimer >= 3.5) { @@ -322,6 +365,7 @@ window.WaveSystem = class WaveSystem { if (this.currentWave >= this.totalWaves && this.mode !== 'survival') { this.waveState = 'completed'; this.isVictory = true; + this.smoothProgress = 1.0; gameState.triggerVictory(); } else { this.waveState = 'waiting'; @@ -338,37 +382,159 @@ window.WaveSystem = class WaveSystem { } draw(ctx) { - // 1. Progress Bar in Top Right - const barX = 685; - const barY = 22; - const barW = 115; - const barH = 22; + if (this.mode === 'izombie') return; - ctx.fillStyle = '#212121'; + // 1. Visual Wave Progress Bar in Top Right HUD + const barX = 680; + const barY = 16; + const barW = 205; + const barH = 24; + + // Outer frame & drop shadow + ctx.save(); + ctx.fillStyle = '#1e1008'; ctx.beginPath(); - ctx.roundRect(barX, barY, barW, barH, 4); + ctx.roundRect(barX, barY, barW, barH, 5); 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'; + // Inset track + ctx.fillStyle = '#110b06'; ctx.beginPath(); - ctx.roundRect(barX + barW * (1 - progress), barY + 2, barW * progress - 2, barH - 4, 3); + ctx.roundRect(barX + 2, barY + 2, barW - 4, barH - 4, 3); ctx.fill(); + // Progress fill with PvZ gradient + const progress = this.mode === 'survival' ? 1.0 : Math.min(1, Math.max(0, this.smoothProgress || 0)); + const fillW = Math.max(0, (barW - 4) * progress); + + if (fillW > 0) { + const grad = ctx.createLinearGradient(barX + 2, barY, barX + 2 + fillW, barY); + const isFinalOrHuge = this.currentWave >= this.totalWaves || (this.totalWaves >= 4 && this.currentWave === this.totalWaves - 1); + if (isFinalOrHuge) { + grad.addColorStop(0, '#e65100'); + grad.addColorStop(0.5, '#f57c00'); + grad.addColorStop(1, '#ffd54f'); + } else { + grad.addColorStop(0, '#1b5e20'); + grad.addColorStop(0.5, '#388e3c'); + grad.addColorStop(1, '#76ff03'); + } + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.roundRect(barX + 2, barY + 2, fillW, barH - 4, 3); + ctx.fill(); + } + + // Flag Markers on the Progress Bar + const flagWaves = this.getFlagWaves(); + for (const flag of flagWaves) { + const flagRatio = flag.wave / this.totalWaves; + const fx = barX + 2 + (barW - 4) * flagRatio; + const isReached = this.currentWave >= flag.wave; + + // Flag pole + ctx.strokeStyle = '#9e9e9e'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(fx, barY + 1); + ctx.lineTo(fx, barY + barH - 2); + ctx.stroke(); + + // Flag banner + ctx.save(); + if (flag.type === 'final') { + // Final wave skull / red flag + ctx.fillStyle = isReached ? '#d50000' : '#8d6e63'; + ctx.beginPath(); + ctx.moveTo(fx, barY + 3); + ctx.lineTo(fx - 12, barY + 7); + ctx.lineTo(fx, barY + 12); + ctx.closePath(); + ctx.fill(); + + // Skull dot / marker + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 8px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('☠️', fx - 1, barY - 2); + } else { + // Huge wave orange/red flag + ctx.fillStyle = isReached ? '#ff3d00' : '#8d6e63'; + ctx.beginPath(); + ctx.moveTo(fx, barY + 3); + ctx.lineTo(fx - 10, barY + 7); + ctx.lineTo(fx, barY + 11); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 8px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('🚩', fx - 1, barY - 2); + } + ctx.restore(); + } + + // Border highlight ctx.strokeStyle = '#8d6e63'; ctx.lineWidth = 2; - ctx.strokeRect(barX, barY, barW, barH); + ctx.beginPath(); + ctx.roundRect(barX, barY, barW, barH, 5); + ctx.stroke(); - ctx.fillStyle = '#fff'; + // Moving Zombie Head icon on progress tip + if (this.mode !== 'survival') { + const zx = barX + 2 + fillW; + const zy = barY + barH / 2; + ctx.save(); + // Green zombie head circle + ctx.fillStyle = '#689f38'; + ctx.strokeStyle = '#33691e'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(zx, zy, 7, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Eyes + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(zx - 2.5, zy - 1.5, 2, 0, Math.PI * 2); + ctx.arc(zx + 2.5, zy - 1.5, 1.5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(zx - 2.5, zy - 1.5, 1, 0, Math.PI * 2); + ctx.arc(zx + 2.5, zy - 1.5, 0.8, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + // Text Label inside progress bar + ctx.fillStyle = '#ffffff'; 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.shadowColor = '#000000'; + ctx.shadowBlur = 4; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + let label = ''; + if (this.mode === 'survival') { + label = `🚩 ВЫЖИВАНИЕ: ФЛАГ ${this.survivalFlags}`; + } else if (this.mode === 'boss' || this.mode === 'bossrush') { + label = `👑 БОСС (${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves})`; + } else if (this.mode === 'roguelike') { + label = `🎲 ЭТАЖ ${this.level} (${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves})`; + } else if (this.currentWave >= this.totalWaves && this.totalWaves > 1) { + label = `☠️ ПОСЛЕДНЯЯ ВОЛНА (${this.totalWaves}/${this.totalWaves})`; + } else if (this.totalWaves >= 4 && this.currentWave === this.totalWaves - 1) { + label = `⚠️ БОЛЬШАЯ ВОЛНА (${this.currentWave}/${this.totalWaves})`; + } else { + label = `ВОЛНА ${Math.min(this.currentWave, this.totalWaves)} / ${this.totalWaves}`; + } ctx.fillText(label, barX + barW / 2, barY + 16); + ctx.restore(); // 2. Center Banner Display if (this.bannerTimer > 0 && this.bannerText) { diff --git a/scripts/test_game.py b/scripts/test_game.py index fc52c48..624c317 100644 --- a/scripts/test_game.py +++ b/scripts/test_game.py @@ -843,6 +843,43 @@ def test_modern_dashboard_and_fullscreen(): for name, cond in dashboard_checks: record(name, "PASS" if cond else "FAIL") +def test_qol_controls_and_backup(): + print("\n--- 11d. QoL Controls, Wave Progress Bar & Data Backup/Restore ---") + html_path = os.path.join(GAME_DIR, "index.html") + with open(html_path, "r", encoding="utf-8") as f: + html_code = f.read() + + game_js_path = os.path.join(GAME_DIR, "js/game.js") + with open(game_js_path, "r", encoding="utf-8") as f: + game_code = f.read() + + wave_js_path = os.path.join(GAME_DIR, "js/systems/wave.js") + with open(wave_js_path, "r", encoding="utf-8") as f: + wave_code = f.read() + + auth_js_path = os.path.join(GAME_DIR, "js/systems/auth.js") + with open(auth_js_path, "r", encoding="utf-8") as f: + auth_code = f.read() + + grid_js_path = os.path.join(GAME_DIR, "js/systems/grid.js") + with open(grid_js_path, "r", encoding="utf-8") as f: + grid_code = f.read() + + qol_checks = [ + ("Wave progress bar HUD drawing logic present", "Wave Progress Bar" in wave_code and "draw(ctx)" in wave_code), + ("Wave progress bar flag & skull markers present", "drawFlags" in wave_code or "flag" in wave_code), + ("Hover and swipe collection in game.js", "checkTouchCollect" in game_code and "mousemove" in game_code), + ("Area/range explosion preview for bomb plants in grid.js", "drawAreaPreview" in grid_code), + ("Plant selection hotkeys 1-6 implemented", "Digit1" in game_code or "Key1" in game_code), + ("Shovel hotkey KeyS implemented", "KeyS" in game_code), + ("Auto-save on beforeunload & visibilitychange present", "beforeunload" in auth_code or "beforeunload" in game_code), + ("Export save file button in HTML and logic in auth.js", "btn-export-save" in html_code and "exportSaveFile" in auth_code), + ("Import save file input/button in HTML and logic in auth.js", "input-import-save" in html_code and "importSaveFile" in auth_code), + ] + + for name, cond in qol_checks: + record(name, "PASS" if cond else "FAIL") + def main(): print("==================================================") print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱") @@ -861,6 +898,7 @@ def main(): test_feedback_and_layout_system() test_plant_shop_and_deck_selection() test_modern_dashboard_and_fullscreen() + test_qol_controls_and_backup() test_http_endpoint() passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")