feat(qol & backup): add wave progress bar, hover collection, area explosion preview, hotkeys, auto-sync and json save export/import

This commit is contained in:
2026-09-17 13:20:29 +00:00
parent 7cd0792799
commit 23918dab1f
7 changed files with 710 additions and 38 deletions
+209 -18
View File
@@ -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 = `
<div class="ach-toast-icon">${icon}</div>
<div class="ach-toast-text">
<div class="ach-toast-subtitle">${subtitle || 'УВЕДОМЛЕНИЕ'}</div>
<div class="ach-toast-title">${title}</div>
</div>
`;
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;
})();
+22
View File
@@ -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';
+140 -1
View File
@@ -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();
}
};
+184 -18
View File
@@ -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) {