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 @@
+
+
+
${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")