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
@@ -445,6 +445,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore Buttons (Profile View) -->
|
||||
<div class="auth-backup-section" style="margin-top: 14px; padding: 10px; background: rgba(0, 0, 0, 0.25); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.15);">
|
||||
<div style="font-size: 12px; color: #b0bec5; text-align: center; margin-bottom: 8px;">💾 Резервная копия сохранений:</div>
|
||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||
<button type="button" id="btn-export-save-profile" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Скачать файл сохранения на устройство">💾 Экспорт в файл (JSON)</button>
|
||||
<button type="button" id="btn-import-save-profile" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Загрузить сохранение из файла">📂 Импорт из файла</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 14px;">
|
||||
<button type="button" id="btn-auth-logout" class="btn btn-danger">🚪 Выйти из аккаунта</button>
|
||||
<button type="button" id="btn-auth-close-profile" class="btn btn-secondary">Закрыть</button>
|
||||
@@ -472,6 +481,15 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Backup & Restore Buttons (Guest / Login View) -->
|
||||
<div class="auth-backup-section" style="margin-top: 6px; margin-bottom: 12px; padding: 10px; background: rgba(0, 0, 0, 0.25); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.15);">
|
||||
<div style="font-size: 12px; color: #b0bec5; text-align: center; margin-bottom: 8px;">💾 Локальные сохранения (без аккаунта):</div>
|
||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||
<button type="button" id="btn-export-save" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Скачать файл сохранения на устройство">💾 Экспорт в файл (JSON)</button>
|
||||
<button type="button" id="btn-import-save" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Загрузить сохранение из файла">📂 Импорт из файла</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-auth-close" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
||||
</div>
|
||||
|
||||
@@ -495,6 +513,9 @@
|
||||
|
||||
<button id="btn-auth-close-reg" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input for importing saves -->
|
||||
<input type="file" id="input-import-save" accept=".json" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+96
-1
@@ -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');
|
||||
|
||||
+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;
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user