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:
1 parent
7cd0792799
commit
23918dab1f
7 files changed
+710
-38
No files matched your search
+209
-18
@@ -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;
|
||||
})();
|
||||
Reference in new issue
Block a user