feat: Initial commit Plants vs Zombies Web Edition v2.4

- 5 Biomes: Day, Night, Pool, Fog, Roof (25 Campaign levels)
- Minigames: Wall-nut Bowling, I Zombie, Roguelike, Boss Rush & Dr. Zomboss
- Systems: Zen Garden, Workshop Crafting, Almanac, Tree of Wisdom, Achievements
- Backend: REST API + SQLite Accounts & Save Synchronization
- Infrastructure: Nginx HTTPS & Docker deployment
This commit is contained in:
2026-09-17 09:30:52 +00:00
commit 6457a8904e
50 changed files with 14486 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
// Bug Reporting & Community Feedback Chat System
window.PvZFeedback = (function() {
const STORAGE_KEY = 'pvz_cached_bugs';
let cachedReports = [];
let currentFilter = 'all';
function getLocalFallback() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch (e) {}
return [
{
id: 1,
author: "Player_Kopri",
category: "bug",
text: "Зомби после поедания растения не шел дальше, а оставался стоять на месте.",
status: "исправлено",
timestamp: "2026-09-16 09:30",
adminReply: "Исправлено в v1.6: сброшен флаг isEating и таймер поедания."
},
{
id: 2,
author: "DaveFan",
category: "bug",
text: "Режим 'Я, Зомби' не работал: сразу после старта выводилось сообщение 'победа'.",
status: "исправлено",
timestamp: "2026-09-16 09:45",
adminReply: "Исправлено в v1.6: устранено состояние гонки при инициализации газона."
},
{
id: 3,
author: "ZombossHater",
category: "visual",
text: "Элементы главного меню и кнопки вываливаются за границы экрана на небольших дисплеях.",
status: "исправлено",
timestamp: "2026-09-16 10:50",
adminReply: "Исправлено в v2.2: адаптивная панель навигации, компактные карточки и вертикальный скролл."
}
];
}
return {
init: function() {
cachedReports = getLocalFallback();
this.fetchReports();
},
getReports: function() {
return cachedReports;
},
fetchReports: async function(onDone) {
try {
const resp = await fetch('/api/bugs', { cache: 'no-cache' });
if (resp.ok) {
const data = await resp.json();
if (Array.isArray(data)) {
cachedReports = data;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {}
}
}
} catch (err) {
console.warn('Could not fetch online bugs, using local cache:', err);
if (cachedReports.length === 0) {
cachedReports = getLocalFallback();
}
}
if (typeof onDone === 'function') onDone(cachedReports);
this.renderFeed(currentFilter);
},
sendReport: async function(author, category, text, onResult) {
const payload = {
author: (author || 'Гость').trim(),
category: category || 'bug',
text: (text || '').trim(),
timestamp: new Date().toISOString()
};
if (!payload.text) {
if (onResult) onResult({ success: false, message: 'Введите описание проблемы' });
return;
}
try {
const resp = await fetch('/api/bugs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
const res = await resp.json();
await this.fetchReports();
if (onResult) onResult({ success: true, report: res.report });
return;
}
} catch (err) {
console.warn('Failed to POST to server, storing locally:', err);
}
// Offline fallback: save into local list
const newId = (cachedReports.reduce((m, r) => Math.max(m, r.id || 0), 0) || 0) + 1;
const localReport = {
id: newId,
author: payload.author,
category: payload.category,
text: payload.text,
status: 'новый',
timestamp: new Date().toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' }),
adminReply: null
};
cachedReports.push(localReport);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedReports));
} catch (e) {}
this.renderFeed(currentFilter);
if (onResult) onResult({ success: true, report: localReport, offline: true });
},
setFilter: function(filter) {
currentFilter = filter;
this.renderFeed(filter);
},
renderFeed: function(filter = currentFilter) {
currentFilter = filter;
const feedEl = document.getElementById('bugreport-feed');
const countBadge = document.getElementById('bug-count-badge');
if (countBadge) {
const total = cachedReports.length;
const word = total % 10 === 1 && total % 100 !== 11 ? 'репорт' : (total % 10 >= 2 && total % 10 <= 4 && (total % 100 < 10 || total % 100 >= 20) ? 'репорта' : 'репортов');
countBadge.textContent = `${total} ${word}`;
}
if (!feedEl) return;
let list = [...cachedReports];
if (filter === 'bug') list = list.filter(r => r.category === 'bug');
else if (filter === 'feature') list = list.filter(r => r.category === 'feature');
else if (filter === 'visual') list = list.filter(r => r.category === 'visual');
else if (filter === 'balance') list = list.filter(r => r.category === 'balance');
else if (filter === 'resolved') list = list.filter(r => r.status === 'исправлено');
if (list.length === 0) {
feedEl.innerHTML = '<div class="bug-empty">Сообщений в этой категории пока нет. Напишите первое! 💬</div>';
return;
}
// Show newest first
list.sort((a, b) => (b.id || 0) - (a.id || 0));
const categoryLabels = {
bug: { text: '🔴 Баг', cls: 'cat-bug' },
feature: { text: '💡 Идея', cls: 'cat-feature' },
visual: { text: '🎨 Графика/UI', cls: 'cat-visual' },
balance: { text: '⚖️ Баланс', cls: 'cat-balance' }
};
const statusLabels = {
'новый': { text: '🔴 Новый', cls: 'status-new' },
'в работе': { text: '🟡 В работе', cls: 'status-progress' },
'исправлено': { text: '✅ Исправлено', cls: 'status-done' }
};
let html = '';
list.forEach(r => {
const cat = categoryLabels[r.category] || categoryLabels.bug;
const st = statusLabels[r.status] || statusLabels['новый'];
const replyHtml = r.adminReply
? `<div class="bug-reply">
<span class="reply-tag">🛠️ Ответ разработчика:</span>
<div class="reply-text">${this.escapeHtml(r.adminReply)}</div>
</div>`
: '';
html += `
<div class="bug-card ${r.status === 'исправлено' ? 'is-fixed' : ''}">
<div class="bug-card-top">
<div class="bug-author">👤 <strong>${this.escapeHtml(r.author || 'Гость')}</strong></div>
<div class="bug-meta">
<span class="bug-category ${cat.cls}">${cat.text}</span>
<span class="bug-status ${st.cls}">${st.text}</span>
<span class="bug-time">🕒 ${this.escapeHtml(r.timestamp || '')}</span>
</div>
</div>
<div class="bug-text">${this.escapeHtml(r.text)}</div>
${replyHtml}
</div>
`;
});
feedEl.innerHTML = html;
},
escapeHtml: function(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
};
})();