feat(gameplay): add crazy dave casino and bank, and zombie mutation lab

This commit is contained in:
Antigravity committed 2026-09-22 09:25:08 +00:00
1 parent d8be8f4366
commit b8db5a123d
16 files changed
+2365 -135

No files matched your search

+238 -20
View File
@@ -131,6 +131,52 @@ window.PvZAuth = (function () {
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
}
/* ─── Profile change event listeners ─── */
const _profileListeners = new Set();
function onProfileChange(fn) {
if (typeof fn === 'function') _profileListeners.add(fn);
}
function offProfileChange(fn) {
_profileListeners.delete(fn);
}
function notifyProfileChange(data) {
_profileListeners.forEach(fn => {
try { fn(data); } catch (e) { console.error('Profile listener error:', e); }
});
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pvz:profile-updated', { detail: data }));
}
} catch (_) {}
}
/* ─── Non-blocking GET server call with Bearer Token Authorization ─── */
async function apiGet(endpoint) {
try {
const headers = {};
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const resp = await fetch(`${API_BASE}/${endpoint}`, {
method: 'GET',
headers
});
if (resp.status === 401) {
clearToken();
const json = await resp.json().catch(() => ({}));
return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' };
}
const json = await resp.json();
return json;
} catch (e) {
return { success: false, message: 'Сервер авторизации недоступен' };
}
}
/* ─── Non-blocking server call with Bearer Token Authorization ─── */
async function apiPost(endpoint, body = {}, keepalive = false) {
try {
@@ -172,41 +218,90 @@ window.PvZAuth = (function () {
/* ─── Debounced save (prevents save storms on rapid updates) ─── */
let _saveTimer = null;
let _hasSyncedFromServer = false;
function scheduleSave() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
_saveTimer = setTimeout(async () => {
const user = localStorage.getItem(CURRENT_USER_KEY);
if (!user || user === 'Гость' || !_cachedData) return;
if (!_hasSyncedFromServer && getToken()) {
await authInstance.fetchProfile();
}
apiPost('save', { username: user, data: _cachedData });
}, 1500);
}
function mergeUserData(serverData) {
function mergeUserData(serverData, localData = null) {
const def = getDefaultData();
if (!serverData) return def;
if (!serverData && !localData) return def;
const base = serverData || localData || def;
const loc = localData || {};
// Combine unlocked plants uniquely
const unlocked = Array.from(new Set([
...def.unlockedPlants,
...(loc.unlockedPlants || []),
...(base.unlockedPlants || [])
]));
// Combine selected deck
const selDeck = (base.selectedDeck && base.selectedDeck.length > 0)
? base.selectedDeck
: ((loc.selectedDeck && loc.selectedDeck.length > 0) ? loc.selectedDeck : def.selectedDeck);
return {
...def,
...serverData,
...loc,
...base,
coins: base.coins !== undefined ? base.coins : (loc.coins !== undefined ? loc.coins : def.coins),
level: Math.max(base.level || 1, loc.level || 1),
maxUnlockedLevel: Math.max(base.maxUnlockedLevel || 1, loc.maxUnlockedLevel || 1, base.level || 1, loc.level || 1),
highScore: Math.max(base.highScore || 0, loc.highScore || 0),
theme: base.theme || loc.theme || def.theme,
stats: {
...def.stats,
...(serverData.stats || {})
...(loc.stats || {}),
...(base.stats || {})
},
upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) },
unlockedPlants: serverData.unlockedPlants || def.unlockedPlants,
selectedDeck: serverData.selectedDeck || def.selectedDeck,
materials: { ...def.materials, ...(serverData.materials || {}) },
plantLevels: { ...def.plantLevels, ...(serverData.plantLevels || {}) },
perks: { ...def.perks, ...(serverData.perks || {}) },
zenGarden: serverData.zenGarden
upgrades: {
...def.upgrades,
...(loc.upgrades || {}),
...(base.upgrades || {}),
unlockedPlants: unlocked,
selectedDeck: selDeck
},
unlockedPlants: unlocked,
selectedDeck: selDeck,
materials: {
...def.materials,
...(loc.materials || {}),
...(base.materials || {})
},
plantLevels: {
...def.plantLevels,
...(loc.plantLevels || {}),
...(base.plantLevels || {})
},
perks: {
...def.perks,
...(loc.perks || {}),
...(base.perks || {})
},
zenGarden: base.zenGarden
? {
...def.zenGarden,
...serverData.zenGarden,
pots: serverData.zenGarden.pots || def.zenGarden.pots,
snail: { ...def.zenGarden.snail, ...(serverData.zenGarden.snail || {}) }
...(loc.zenGarden || {}),
...base.zenGarden,
pots: base.zenGarden.pots || (loc.zenGarden && loc.zenGarden.pots) || def.zenGarden.pots,
snail: { ...def.zenGarden.snail, ...((loc.zenGarden && loc.zenGarden.snail) || {}), ...(base.zenGarden.snail || {}) }
}
: def.zenGarden,
treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom,
achievements: serverData.achievements || {}
: (loc.zenGarden || def.zenGarden),
treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom,
achievements: {
...(loc.achievements || {}),
...(base.achievements || {})
}
};
}
@@ -228,9 +323,13 @@ window.PvZAuth = (function () {
getDefaultData,
validateUsername,
validatePassword,
apiGet,
apiPost,
scheduleSave,
showNotification,
onProfileChange,
offProfileChange,
notifyProfileChange,
/**
* User Login
@@ -263,7 +362,11 @@ window.PvZAuth = (function () {
if (window.GameEngine && window._pvzEngine) {
window._pvzEngine.loadUserData();
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
}
notifyProfileChange(_cachedData);
if (typeof callback === 'function') callback(true, _cachedData, null);
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
} else {
@@ -305,7 +408,11 @@ window.PvZAuth = (function () {
if (window.GameEngine && window._pvzEngine) {
window._pvzEngine.loadUserData();
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
}
notifyProfileChange(_cachedData);
if (typeof callback === 'function') callback(true, _cachedData, null);
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
} else {
@@ -318,6 +425,7 @@ window.PvZAuth = (function () {
loginAsGuest() {
clearToken();
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
notifyProfileChange(this.getUserData());
return { success: true, user: 'Гость' };
},
@@ -334,16 +442,118 @@ window.PvZAuth = (function () {
this.loginAsGuest();
},
/**
* Strictly Server-Authoritative Profile Sync:
* When user is logged in, fetches /api/auth/profile (with fallback to /api/users)
* and applies it directly to the game state.
*/
async fetchProfile() {
const token = getToken();
const user = this.getCurrentUser();
if (!token || user === 'Гость') return null;
try {
let profileData = null;
// 1. Try dedicated profile endpoint
const res = await apiGet('profile');
if (res && res.success && res.data) {
profileData = res.data;
} else if (res && res.status === 401) {
clearToken();
return null;
}
// 2. Fallback to /api/users which is universally supported across backend versions
if (!profileData) {
try {
const usersResp = await fetch('/api/users');
if (usersResp.ok) {
const usersJson = await usersResp.json();
if (usersJson && usersJson.success && Array.isArray(usersJson.users)) {
const matched = usersJson.users.find(u => u.username && u.username.toLowerCase() === user.toLowerCase());
if (matched) {
profileData = matched;
}
}
}
} catch (_) {}
}
if (profileData) {
_hasSyncedFromServer = true;
_cachedData = mergeUserData(profileData);
localSave(_cachedData);
if (window._pvzEngine) {
window._pvzEngine.loadUserData();
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
}
notifyProfileChange(_cachedData);
return _cachedData;
}
} catch (e) {
console.warn('Profile fetch from server error:', e);
}
return null;
},
async syncFromServer() {
return this.fetchProfile();
},
/**
* Active Device Sessions Management
*/
async fetchSessions() {
const token = getToken();
if (!token || this.isGuest()) return [];
try {
const res = await apiGet('sessions');
if (res && res.success && Array.isArray(res.sessions)) {
return res.sessions.map(s => ({
token: s.token || '',
deviceName: s.deviceName || s.device_name || 'Браузер / Компьютер',
ipAddress: s.ipAddress || s.ip_address || '127.0.0.1',
createdAt: s.createdAt || s.created_at || '',
lastActive: s.lastActive || s.last_active || s.createdAt || '',
isCurrent: Boolean(s.isCurrent || s.is_current || (token && s.token === token))
}));
}
} catch (e) {
console.error('Fetch sessions error:', e);
}
return [];
},
async terminateSession(sessionToken) {
if (!sessionToken) return { success: false, message: 'Не указан токен сессии' };
const res = await apiPost('sessions/terminate', { sessionToken, token: sessionToken });
return res || { success: false, message: 'Ошибка сети' };
},
async terminateOtherSessions() {
const res = await apiPost('sessions/terminate', { allOthers: true, all_others: true });
return res || { success: false, message: 'Ошибка сети' };
},
getUserData() {
if (_cachedData) return _cachedData;
const local = localLoad();
if (local) {
_cachedData = mergeUserData(local);
return _cachedData;
} else {
_cachedData = getDefaultData();
}
// Background refresh from server if logged in
if (!this.isGuest() && getToken()) {
this.fetchProfile();
}
_cachedData = getDefaultData();
return _cachedData;
},
@@ -362,6 +572,7 @@ window.PvZAuth = (function () {
/** Force immediate server save (call on game-over / victory / visibility hidden / unload) */
flushSave(keepalive = false) {
if (this.isGuest() || !_cachedData) return;
if (!_hasSyncedFromServer && getToken()) return; // Don't flush stale unsynced local cache over server
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive);
},
@@ -527,6 +738,13 @@ window.PvZAuth = (function () {
}
authInstance.flushSave(true);
});
// Auto-sync user data on startup if logged in
setTimeout(() => {
if (!authInstance.isGuest() && authInstance.getToken()) {
authInstance.syncFromServer();
}
}, 150);
}
return authInstance;
+76 -5
View File
@@ -73,30 +73,101 @@ window.CampaignSystem = (function() {
{ level: 25, world: 5, stage: 5, code: '5-5', title: 'Доктор Зомбосс!', desc: 'Финальная битва против шагающего робота Zombot!', waves: 1, biome: 'roof', isBoss: true, reward: 1000 }
];
let _activeLevels = [...LEVELS];
let _serverLoaded = false;
async function loadLevelsFromServer() {
try {
const resp = await fetch('/api/campaign/levels');
if (!resp.ok) return _activeLevels;
const data = await resp.json();
if (data && data.success && Array.isArray(data.levels) && data.levels.length > 0) {
_activeLevels = data.levels.map(lvl => {
const num = parseInt(lvl.level, 10) || 1;
const defaultLvl = LEVELS.find(d => d.level === num) || {};
let wavesCount = 4;
if (lvl.waves && typeof lvl.waves === 'object' && Array.isArray(lvl.waves.list)) {
wavesCount = lvl.waves.list.length;
} else if (Array.isArray(lvl.waves)) {
wavesCount = lvl.waves.length;
} else if (typeof lvl.waves === 'number') {
wavesCount = lvl.waves;
} else if (defaultLvl.waves) {
wavesCount = defaultLvl.waves;
}
const modifiers = (lvl.modifiers && typeof lvl.modifiers === 'object') ? lvl.modifiers : {};
const wavesConfig = lvl.wavesConfig || lvl.waves || defaultLvl.wavesConfig || null;
return {
...defaultLvl,
...lvl,
waves: wavesCount,
modifiers: {
speedMultiplier: modifiers.speedMultiplier || lvl.speedMultiplier || 1.0,
hpMultiplier: modifiers.hpMultiplier || lvl.hpMultiplier || 1.0,
startingSun: modifiers.startingSun !== undefined ? modifiers.startingSun : (lvl.startingSun !== undefined ? lvl.startingSun : null)
},
wavesConfig,
isBoss: Boolean(lvl.isBoss || lvl.is_boss || num === 25 || defaultLvl.isBoss)
};
});
_serverLoaded = true;
return _activeLevels;
}
} catch (e) {
console.warn('Campaign server load error, using offline standard levels:', e);
}
return _activeLevels;
}
// Load levels on initialization
if (typeof window !== 'undefined') {
setTimeout(() => {
loadLevelsFromServer();
}, 50);
}
return {
WORLDS,
LEVELS,
DAVE_DIALOGUES,
loadLevelsFromServer,
isServerLoaded() {
return _serverLoaded;
},
getAllLevels() {
return _activeLevels;
},
getDaveDialogue(levelNum, mode = 'adventure') {
if (mode === 'boss' || mode === 'bossrush') {
return DAVE_DIALOGUES['5-5'];
}
const num = parseInt(levelNum, 10) || 1;
const levelObj = LEVELS.find(l => l.level === num);
if (levelObj && DAVE_DIALOGUES[levelObj.code]) {
return DAVE_DIALOGUES[levelObj.code];
const levelObj = this.getLevel(num);
if (levelObj) {
if (levelObj.dialogue && typeof levelObj.dialogue === 'object' && levelObj.dialogue.text) {
return levelObj.dialogue;
}
if (levelObj.daveDialogue && typeof levelObj.daveDialogue === 'object' && levelObj.daveDialogue.text) {
return levelObj.daveDialogue;
}
if (DAVE_DIALOGUES[levelObj.code]) {
return DAVE_DIALOGUES[levelObj.code];
}
}
return null;
},
getLevel(levelNum) {
const num = parseInt(levelNum, 10) || 1;
return LEVELS.find(l => l.level === num) || LEVELS[0];
return _activeLevels.find(l => l.level === num) || LEVELS.find(l => l.level === num) || LEVELS[0];
},
getWorld(worldId) {
return WORLDS.find(w => w.id === worldId) || WORLDS[0];
},
getLevelsByWorld(worldId) {
return LEVELS.filter(l => l.world === worldId);
return _activeLevels.filter(l => l.world === worldId);
},
formatLevel(levelNum) {
const item = this.getLevel(levelNum);
+261
View File
@@ -0,0 +1,261 @@
// Crazy Dave's Casino & Bank System (Slot Machine & Bank Deposits/Loans)
window.CasinoSystem = class CasinoSystem {
constructor(economy) {
this.economy = economy;
// Slot machine symbols
this.symbols = [
{ id: 'sunflower', icon: '🌻', name: 'Подсолнух', weight: 30, mult: 4 },
{ id: 'pea', icon: '🟢', name: 'Горох', weight: 25, mult: 8 },
{ id: 'wallnut', icon: '🌰', name: 'Орех', weight: 20, mult: 12 },
{ id: 'cherry', icon: '🍒', name: 'Вишня', weight: 12, mult: 25 },
{ id: 'diamond', icon: '💎', name: 'Алмаз', weight: 6, mult: 60, crystals: 2 },
{ id: 'jackpot7', icon: '7️⃣', name: 'Семёрка', weight: 3, mult: 200, crystals: 5 },
{ id: 'brains', icon: '🧠', name: 'Мозги', weight: 4, mult: 0, isZombie: true }
];
// Bank State
this.deposit = 0;
this.depositStartTime = 0;
this.interestRatePerHour = 0.05; // 5% per hour (up to 48 hours max)
this.loan = 0;
this.loanLimit = 2500;
this.loanInterest = 0.10; // 10% fee
// Statistics
this.spinsTotal = 0;
this.totalWon = 0;
this.jackpotsWon = 0;
this.isSpinning = false;
this.currentBet = 50;
this.lastReels = ['🌻', '🟢', '🌰'];
this.loadFromAuth();
}
loadFromAuth() {
if (window.PvZAuth) {
const uData = window.PvZAuth.getUserData();
if (uData.casino) {
const c = uData.casino;
this.deposit = Number(c.deposit) || 0;
this.depositStartTime = Number(c.depositStartTime) || 0;
this.loan = Number(c.loan) || 0;
this.spinsTotal = Number(c.spinsTotal) || 0;
this.totalWon = Number(c.totalWon) || 0;
this.jackpotsWon = Number(c.jackpotsWon) || 0;
}
}
}
saveToAuth() {
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
window.PvZAuth.saveUserData({
casino: {
deposit: this.deposit,
depositStartTime: this.depositStartTime,
loan: this.loan,
spinsTotal: this.spinsTotal,
totalWon: this.totalWon,
jackpotsWon: this.jackpotsWon
}
});
}
}
// Get random symbol based on weights
getRandomSymbol() {
const totalWeight = this.symbols.reduce((sum, s) => sum + s.weight, 0);
let rand = Math.random() * totalWeight;
for (const s of this.symbols) {
if (rand < s.weight) return s;
rand -= s.weight;
}
return this.symbols[0];
}
// Spin the reels
spin(bet = this.currentBet) {
if (this.isSpinning) return { error: 'Уже крутится!' };
if (!this.economy || this.economy.totalCoins < bet) {
return { error: 'Недостаточно монет для ставки!' };
}
// Deduct bet
this.economy.totalCoins -= bet;
this.economy.saveProfileData();
this.spinsTotal++;
this.isSpinning = true;
// Pick 3 symbols
const s1 = this.getRandomSymbol();
const s2 = this.getRandomSymbol();
const s3 = this.getRandomSymbol();
this.lastReels = [s1.icon, s2.icon, s3.icon];
let payout = 0;
let crystalsAwarded = 0;
let message = 'Повезет в следующий раз!';
let isJackpot = false;
// Check matches
if (s1.id === s2.id && s2.id === s3.id) {
// 3 of a kind
if (s1.id === 'jackpot7') {
payout = bet * s1.mult;
crystalsAwarded = s1.crystals || 5;
isJackpot = true;
this.jackpotsWon++;
message = `🔥 МЕГА ДЖЕКПОТ! +${payout} монет и +${crystalsAwarded} алмазов!`;
} else if (s1.id === 'brains') {
payout = 0;
message = '🧟 АТАКА ЗОМБИ! Все барабаны съедены!';
} else {
payout = bet * s1.mult;
if (s1.crystals) crystalsAwarded = s1.crystals;
message = `🎉 СУПЕР ВЫИГРЫШ: 3x ${s1.name}! +${payout} монет!`;
}
} else if (s1.id === s2.id || s2.id === s3.id || s1.id === s3.id) {
// 2 of a kind
const matched = (s1.id === s2.id) ? s1 : (s2.id === s3.id ? s2 : s1);
if (!matched.isZombie) {
payout = Math.round(bet * Math.max(1.5, matched.mult * 0.35));
message = `✨ Выигрыш: пара ${matched.icon}! +${payout} монет`;
}
}
if (payout > 0) {
this.economy.totalCoins += payout;
this.totalWon += payout;
this.economy.saveProfileData();
if (crystalsAwarded > 0 && this.economy.engine && this.economy.engine.workshop) {
this.economy.engine.workshop.addMaterial('crystals', crystalsAwarded);
}
if (window.PvZAudio) {
if (isJackpot) window.PvZAudio.playWin();
else window.PvZAudio.playCoin();
}
}
this.saveToAuth();
return {
reels: [s1, s2, s3],
icons: [s1.icon, s2.icon, s3.icon],
payout,
crystals: crystalsAwarded,
isJackpot,
message,
coinsLeft: this.economy.totalCoins
};
}
// Calculate accrued interest on deposit
getAccruedInterest() {
if (this.deposit <= 0 || !this.depositStartTime) return 0;
const elapsedMs = Date.now() - this.depositStartTime;
const hours = Math.min(elapsedMs / (1000 * 3600), 48); // max 48h cap
return Math.floor(this.deposit * (this.interestRatePerHour * hours));
}
// Deposit coins into bank
depositCoins(amount) {
amount = Math.floor(amount);
if (amount <= 0) return { success: false, message: 'Некорректная сумма' };
if (!this.economy || this.economy.totalCoins < amount) {
return { success: false, message: 'Недостаточно монет на руках' };
}
// Collect any prior accrued interest
const interest = this.getAccruedInterest();
const priorDeposit = this.deposit;
this.economy.totalCoins -= amount;
this.deposit = priorDeposit + interest + amount;
this.depositStartTime = Date.now();
this.economy.saveProfileData();
this.saveToAuth();
if (window.PvZAudio) window.PvZAudio.playCoin();
return {
success: true,
deposit: this.deposit,
addedInterest: interest,
coinsLeft: this.economy.totalCoins,
message: `Успешно внесено ${amount} монет! (Начислено %: +${interest})`
};
}
// Withdraw full deposit + interest
withdrawDeposit() {
if (this.deposit <= 0) return { success: false, message: 'В банке нет вклада' };
const interest = this.getAccruedInterest();
const totalPayout = this.deposit + interest;
this.economy.totalCoins += totalPayout;
this.deposit = 0;
this.depositStartTime = 0;
this.economy.saveProfileData();
this.saveToAuth();
if (window.PvZAudio) window.PvZAudio.playWin();
return {
success: true,
payout: totalPayout,
interest,
coinsLeft: this.economy.totalCoins,
message: `Снято из банка: ${totalPayout} монет (включая % доход: +${interest})`
};
}
// Take a loan from Crazy Dave
takeLoan(amount) {
amount = Math.floor(amount);
if (this.loan > 0) {
return { success: false, message: `У вас уже есть непогашенный кредит: ${this.loan} монет!` };
}
if (amount <= 0 || amount > this.loanLimit) {
return { success: false, message: `Лимит кредита: от 100 до ${this.loanLimit} монет` };
}
const totalDebt = Math.round(amount * (1 + this.loanInterest));
this.loan = totalDebt;
this.economy.totalCoins += amount;
this.economy.saveProfileData();
this.saveToAuth();
if (window.PvZAudio) window.PvZAudio.playCoin();
return {
success: true,
amount,
debt: totalDebt,
coinsLeft: this.economy.totalCoins,
message: `Кредит выдан! Получено +${amount} монет. К возврату: ${totalDebt} монет.`
};
}
// Repay part or all loan
repayLoan(amount) {
if (this.loan <= 0) return { success: false, message: 'У вас нет долгов!' };
amount = Math.min(this.loan, Math.floor(amount));
if (amount <= 0 || this.economy.totalCoins < amount) {
return { success: false, message: 'Недостаточно монет для погашения' };
}
this.economy.totalCoins -= amount;
this.loan -= amount;
this.economy.saveProfileData();
this.saveToAuth();
if (window.PvZAudio) window.PvZAudio.playCoin();
return {
success: true,
paid: amount,
debtLeft: this.loan,
coinsLeft: this.economy.totalCoins,
message: this.loan === 0 ? '🎉 КРЕДИТ ПОЛНОСТЬЮ ПОГАШЕН!' : `Погашено ${amount} монет. Остаток долга: ${this.loan}`
};
}
};
+5
View File
@@ -516,6 +516,11 @@ window.CombatSystem = class CombatSystem {
}
}
// In I, Zombie mode, zombies are spawned by the player, so defeating them should not drop coins/loot
if (gameState.currentMode === 'izombie') {
return;
}
const perkBonus = gameState.workshop ? gameState.workshop.getPerkLevel('luckyDrop') * 0.35 : 0;
// Coins Drop
+17 -10
View File
@@ -21,7 +21,7 @@ window.PLANT_CATALOGUE = [
// Economy, Sun, Coin Drops & Crazy Dave's Shop System
window.EconomySystem = class EconomySystem {
constructor() {
this.sun = 50;
this.sun = window.PvZConfig ? window.PvZConfig.getStartingSun() : 50;
this.suns = [];
this.coins = [];
this.skySunTimer = 0;
@@ -42,7 +42,7 @@ window.EconomySystem = class EconomySystem {
this.allCards = window.PLANT_CATALOGUE.map(p => ({
id: p.id,
name: p.name,
cost: p.cost,
cost: window.PvZConfig ? window.PvZConfig.getPlantSunCost(p.id, p.cost) : p.cost,
cooldown: p.cooldown,
timer: 0
}));
@@ -69,7 +69,7 @@ window.EconomySystem = class EconomySystem {
if (!p) return { success: false, message: 'Растение не найдено' };
if (this.isPlantUnlocked(plantId)) return { success: false, message: 'Растение уже куплено' };
const price = p.price || 0;
const price = window.PvZConfig ? window.PvZConfig.getPlantShopPrice(plantId) : (p.price || 0);
if (this.totalCoins < price) {
return { success: false, message: `Недостаточно монет! Требуется ${price} 💰 (у вас: ${this.totalCoins} 💰)` };
}
@@ -270,7 +270,7 @@ window.EconomySystem = class EconomySystem {
}
reset() {
let baseSun = this.isConveyor ? 0 : (this.isIZombie ? 200 : 50);
let baseSun = this.isConveyor ? 0 : (this.isIZombie ? 200 : (window.PvZConfig ? window.PvZConfig.getStartingSun() : 50));
if (!this.isConveyor && !this.isIZombie && this.workshop) {
baseSun += this.workshop.getPerkLevel('startSun') * 50;
}
@@ -330,11 +330,13 @@ window.EconomySystem = class EconomySystem {
}
spawnSun(x, y, value = 25, isFalling = false, targetY = null) {
const sunMult = window.PvZConfig ? window.PvZConfig.getSunMultiplier() : 1.0;
const finalVal = Math.max(5, Math.round(value * sunMult));
this.suns.push({
x,
y,
targetY: targetY !== null ? targetY : y,
value,
value: finalVal,
isFalling,
fallSpeed: 90,
radius: 26,
@@ -346,12 +348,14 @@ window.EconomySystem = class EconomySystem {
}
spawnCoin(x, y, type = 'silver') {
const val = type === 'diamond' ? 200 : (type === 'gold' ? 50 : 10);
const dropMult = window.PvZConfig ? window.PvZConfig.getCoinDropRate() : 1.0;
const baseVal = type === 'diamond' ? 200 : (type === 'gold' ? 50 : 10);
const finalVal = Math.max(1, Math.round(baseVal * dropMult));
this.coins.push({
x,
y,
type,
value: val,
value: finalVal,
radius: 16,
life: 12.0,
collecting: false,
@@ -363,9 +367,10 @@ window.EconomySystem = class EconomySystem {
update(dt) {
this.animTime += dt;
// Cooldown timers with fastCool perk bonus
// Cooldown timers with fastCool perk bonus and PvZConfig
const fastCoolLvl = this.workshop ? this.workshop.getPerkLevel('fastCool') : 0;
const cooldownMultiplier = 1 + fastCoolLvl * 0.15;
const cfgCooldownMult = window.PvZConfig ? (1 / Math.max(0.1, window.PvZConfig.getPlantCooldownMult())) : 1.0;
const cooldownMultiplier = (1 + fastCoolLvl * 0.15) * cfgCooldownMult;
for (const card of this.cards) {
if (card.timer > 0) {
card.timer = Math.max(0, card.timer - dt * cooldownMultiplier);
@@ -375,7 +380,9 @@ window.EconomySystem = class EconomySystem {
// Sky sun spawner (disabled at night and in conveyor mode)
if (!this.isNight && !this.isConveyor) {
this.skySunTimer += dt;
if (this.skySunTimer >= this.skySunInterval) {
const sunDropMult = window.PvZConfig ? window.PvZConfig.getSunDropRate() : 1.0;
const effectiveInterval = this.skySunInterval / Math.max(0.2, sunDropMult);
if (this.skySunTimer >= effectiveInterval) {
this.skySunTimer = 0;
const spawnX = 140 + Math.random() * 640;
const targetY = 140 + Math.random() * 380;
+80
View File
@@ -40,6 +40,7 @@ window.LeaderboardSystem = class LeaderboardSystem {
};
this.loadFromAuth();
this.fetchAllLeaderboards();
}
loadFromAuth() {
@@ -59,15 +60,77 @@ window.LeaderboardSystem = class LeaderboardSystem {
}
}
async fetchAllLeaderboards() {
try {
const resp = await fetch('/api/leaderboard');
if (resp.ok) {
const json = await resp.json();
if (json && json.success && json.records) {
for (const [mode, rows] of Object.entries(json.records)) {
if (Array.isArray(rows) && rows.length > 0) {
this.mergeServerRecords(mode, rows);
}
}
if (window._pvzEngine && typeof window._pvzEngine.renderLeaderboardUI === 'function') {
window._pvzEngine.renderLeaderboardUI(this.selectedMode);
}
}
}
} catch (_) {}
}
async fetchLeaderboard(mode) {
try {
const resp = await fetch(`/api/leaderboard?mode=${encodeURIComponent(mode)}`);
if (resp.ok) {
const json = await resp.json();
if (json && json.success && Array.isArray(json.records)) {
this.mergeServerRecords(mode, json.records);
if (window._pvzEngine && typeof window._pvzEngine.renderLeaderboardUI === 'function') {
window._pvzEngine.renderLeaderboardUI(mode);
}
}
}
} catch (_) {}
}
mergeServerRecords(mode, serverRows) {
if (!this.records[mode]) {
this.records[mode] = [];
}
const combined = [...this.records[mode]];
for (const sr of serverRows) {
const existing = combined.find(r => r.name === sr.name);
if (existing) {
if (sr.score > existing.score) {
existing.score = sr.score;
existing.detail = sr.detail;
existing.date = sr.date;
}
} else {
combined.push({
name: sr.name,
score: sr.score,
detail: sr.detail,
date: sr.date
});
}
}
combined.sort((a, b) => b.score - a.score);
this.records[mode] = combined.slice(0, 10);
}
submitScore(mode, score, detail = '') {
if (!this.records[mode]) {
this.records[mode] = [];
}
let playerName = 'Игрок';
let token = null;
if (window.PvZAuth) {
playerName = window.PvZAuth.getCurrentUser();
if (window.PvZAuth.isGuest()) playerName = 'Гость';
token = window.PvZAuth.getToken();
}
const today = new Date().toISOString().slice(0, 10);
@@ -94,6 +157,23 @@ window.LeaderboardSystem = class LeaderboardSystem {
// Keep top 10
this.records[mode] = this.records[mode].slice(0, 10);
this.saveToAuth();
// Submit to server leaderboard asynchronously
try {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
fetch('/api/leaderboard', {
method: 'POST',
headers,
body: JSON.stringify({
mode,
score,
detail: detail || `${score} очков`,
name: playerName,
token
})
}).then(r => r.json()).catch(() => {});
} catch (_) {}
}
getEntries(mode) {
+12 -2
View File
@@ -12,7 +12,8 @@ window.TreeSystem = class TreeSystem {
future: false,
daisies: false,
dance: false,
pinata: false
pinata: false,
shrek: false
};
// Wisdom unlocked at various heights
@@ -50,6 +51,12 @@ window.TreeSystem = class TreeSystem {
text: '«Смерть нежити может приносить радость. Введи код daisies — при падении зомби на земле будут расцветать прекрасные маргаритки!»',
cheat: 'daisies'
},
{
height: 60,
title: 'Секретный код: SHREK',
text: '«Легенда болот гласит: введи shrek во время боя, и гигантский зелёный великан сокрушит орду зомби землетрясением!»',
cheat: 'shrek'
},
{
height: 75,
title: 'Секрет Доктора Зомбосса',
@@ -124,10 +131,13 @@ window.TreeSystem = class TreeSystem {
buffer += char;
if (buffer.length > 15) buffer = buffer.slice(-15);
for (const code of ['mustache', 'future', 'daisies', 'dance', 'pinata']) {
for (const code of ['mustache', 'future', 'daisies', 'dance', 'pinata', 'shrek']) {
if (buffer.endsWith(code)) {
const state = this.toggleCheat(code);
this.showCheatToast(code, state);
if (code === 'shrek' && window._pvzEngine && window._pvzEngine.isRunning) {
window._pvzEngine.triggerShrekEasterEgg();
}
buffer = '';
break;
}
+78 -1
View File
@@ -49,7 +49,17 @@ window.WaveSystem = class WaveSystem {
if (campLvl.isBoss) {
this.mode = 'boss';
}
this.customWavesConfig = campLvl.wavesConfig || campLvl.waves || null;
this.modifiers = campLvl.modifiers || {};
this.speedMultiplier = this.modifiers.speedMultiplier || campLvl.speedMultiplier || 1.0;
this.hpMultiplier = this.modifiers.hpMultiplier || campLvl.hpMultiplier || 1.0;
this.startingSun = this.modifiers.startingSun !== undefined ? this.modifiers.startingSun : (campLvl.startingSun !== undefined ? campLvl.startingSun : null);
} else {
this.customWavesConfig = null;
this.modifiers = {};
this.speedMultiplier = 1.0;
this.hpMultiplier = 1.0;
this.startingSun = null;
if (level <= 5) {
this.biome = 'day';
this.totalWaves = (level === 1) ? 3 : 4;
@@ -209,7 +219,74 @@ window.WaveSystem = class WaveSystem {
return;
}
// Adventure Campaign Waves
// Adventure Campaign Waves (Custom Server Waves or Standard Biome Progression)
if (this.customWavesConfig) {
let customWave = null;
if (Array.isArray(this.customWavesConfig)) {
customWave = this.customWavesConfig.find(w => w && w.wave === this.currentWave) || this.customWavesConfig[this.currentWave - 1];
} else if (typeof this.customWavesConfig === 'object') {
if (Array.isArray(this.customWavesConfig.list)) {
customWave = this.customWavesConfig.list.find(w => w && w.wave === this.currentWave) || this.customWavesConfig.list[this.currentWave - 1];
} else {
customWave = this.customWavesConfig[String(this.currentWave)] || this.customWavesConfig[this.currentWave - 1];
}
}
if (customWave && (customWave.squad || customWave.zombies)) {
const isLastWave = this.currentWave >= this.totalWaves;
const defaultBanner = isLastWave ? '☠️ ПОСЛЕДНЯЯ ВОЛНА!' : `ВОЛНА ${this.currentWave} / ${this.totalWaves}`;
this.bannerText = customWave.banner || customWave.bannerText || defaultBanner;
this.bannerTimer = customWave.bannerTimer || (isLastWave ? 4.0 : 3.0);
if (customWave.isHuge || customWave.is_huge || isLastWave || this.bannerText.includes('БОЛЬШАЯ') || this.bannerText.includes('ПОСЛЕДНЯЯ')) {
if (window.PvZAudio) window.PvZAudio.playHugeWave();
}
this.spawnQueue = [];
const rawSquad = customWave.squad || customWave.zombies || [];
if (Array.isArray(rawSquad)) {
for (let i = 0; i < rawSquad.length; i++) {
const item = rawSquad[i];
const zType = typeof item === 'string' ? item : (item.type || 'normal');
const delay = (typeof item === 'object' && item.delay !== undefined) ? item.delay : (2.0 + i * 2.2);
let row = (typeof item === 'object' && item.row !== undefined && item.row >= 0) ? item.row : null;
if (row === null) {
row = (zType === 'duckytube' && (this.biome === 'pool' || this.biome === 'fog'))
? (Math.random() > 0.5 ? 2 : 3)
: this.randomRow(gameState);
}
this.spawnQueue.push({ delay, type: zType, row });
}
}
// Night mode gravestones rise on final or huge wave
if (this.biome === 'night' && (isLastWave || customWave.isHuge)) {
for (const grave of gameState.grid.graves) {
setTimeout(() => {
if (gameState.isRunning) {
const bounds = gameState.grid.getCellBounds(grave.col, grave.row);
const z = new window.Zombie(grave.row, 'normal', bounds.centerX);
z.y = bounds.centerY - 5;
if (this.hpMultiplier && this.hpMultiplier !== 1.0) {
z.maxHp *= this.hpMultiplier;
z.hp *= this.hpMultiplier;
}
if (this.speedMultiplier && this.speedMultiplier !== 1.0) {
z.speed *= this.speedMultiplier;
}
gameState.zombies.push(z);
gameState.combat.createExplosionParticles(bounds.centerX, bounds.centerY);
}
}, 2500);
}
}
this.initialWaveZombies = Math.max(1, this.spawnQueue.length);
return;
}
}
// Default Biome Standard Campaign Wave Progression
if (this.currentWave === 1) {
this.bannerText = 'ПЕРВЫЕ ЗОМБИ...';
this.bannerTimer = 3.0;
+3 -1
View File
@@ -205,12 +205,14 @@ window.ZenGardenSystem = class ZenGardenSystem {
const baseX = 120 + potCol * 150;
const baseY = 95 + potRow * 125;
const dropMult = window.PvZConfig ? window.PvZConfig.getZenDropRateMult() : 1.0;
const baseVal = coinType === 'diamond' ? 200 : (coinType === 'gold' ? 50 : 10);
this.gardenCoins.push({
x: baseX + (Math.random() * 30 - 15),
y: baseY + 30,
vy: -140,
type: coinType,
value: coinType === 'diamond' ? 200 : (coinType === 'gold' ? 50 : 10),
value: Math.max(1, Math.round(baseVal * dropMult)),
collected: false,
alpha: 1.0,
anim: 0
+189
View File
@@ -0,0 +1,189 @@
// Zombie Lab & Mutation System for "I, Zombie" mode
window.ZombieLabSystem = class ZombieLabSystem {
constructor(economy, workshop) {
this.economy = economy;
this.workshop = workshop;
// Upgradable zombie definitions
this.zombies = {
normal: {
id: 'normal',
name: 'Обычный зомби',
icon: '🧟',
tiers: {
1: { title: 'Базовый ходок', desc: 'HP 200, стандартная скорость' },
2: { title: 'Спринтер', desc: 'Скорость бега +35%', cost: { coins: 150, scrapMetal: 8 } },
3: { title: 'Зомби-Вампир', desc: 'Лечит +45 HP за каждый укус растения', cost: { coins: 350, scrapMetal: 15, bioHumus: 10 } }
}
},
conehead: {
id: 'conehead',
name: 'Зомби с конусом',
icon: '⚠️',
tiers: {
1: { title: 'Дорожный конус', desc: 'HP 560 (конус поглощает урон)' },
2: { title: 'Титановый конус', desc: 'HP 800 + устойчивость к критам', cost: { coins: 200, scrapMetal: 15 } },
3: { title: 'Шипованный шлем', desc: 'HP 1050 + отражает 25% урона стрелкам', cost: { coins: 450, scrapMetal: 30, crystals: 1 } }
}
},
polevaulter: {
id: 'polevaulter',
name: 'Зомби с шестом',
icon: '🏃',
tiers: {
1: { title: 'Легкоатлет', desc: 'Быстрый разбег, 1 прыжок через растение' },
2: { title: 'Двойной шест', desc: 'Может перепрыгнуть 2 растения подряд!', cost: { coins: 250, scrapMetal: 20, bioHumus: 10 } },
3: { title: 'Турбо-спринтер', desc: 'Спринт +50% скорости даже после приземления', cost: { coins: 500, scrapMetal: 35, crystals: 1 } }
}
},
buckethead: {
id: 'buckethead',
name: 'Зомби с ведром',
icon: '🪣',
tiers: {
1: { title: 'Стальное ведро', desc: 'HP 1300 тяжелая броня' },
2: { title: 'Тяжелый сплав', desc: 'HP 1800 колоссальная прочность', cost: { coins: 300, scrapMetal: 25 } },
3: { title: 'Крио-изоляция', desc: 'HP 2300 + полный иммунитет к замедлению льдом!', cost: { coins: 650, scrapMetal: 45, crystals: 2 } }
}
},
football: {
id: 'football',
name: 'Зомби-футболист',
icon: '🏈',
tiers: {
1: { title: 'Квотербек', desc: 'HP 1600 суперскорость и шлем' },
2: { title: 'Тяжелый лайнмен', desc: 'HP 2200 + поглощает 20% урона', cost: { coins: 400, scrapMetal: 35 } },
3: { title: 'Безумный таран', desc: 'HP 2800 + сминает картофельную мину без взрыва!', cost: { coins: 800, scrapMetal: 60, crystals: 2 } }
}
}
};
// User levels (1..3)
this.levels = {
normal: 1,
conehead: 1,
polevaulter: 1,
buckethead: 1,
football: 1
};
// Unlocked stage in I, Zombie campaign (1..5)
this.unlockedStage = 1;
this.selectedStage = 1;
this.stages = [
{ id: 1, title: '1. Зелёный Двор', desc: 'Базовый газон Дэйва. Съешьте все подсолнухи!', reward: 100 },
{ id: 2, title: '2. Ночной Дозор', desc: 'Тёмный двор с Дымогрибами и Могилами', reward: 150 },
{ id: 3, title: '3. Водный Штурм', desc: '6 дорожек, Кувшинки и тройные Горохострелы', reward: 220 },
{ id: 4, title: '4. Ореховый Бастион', desc: 'Мощный частокол Стеноорехов и Картофельных Мин', reward: 300 },
{ id: 5, title: '5. РЕЙД-БОСС: Золотой Подсолнух', desc: 'Эпический бой против гигантского Подсолнуха-Босса 5000 HP!', reward: 600, crystals: 2, isBoss: true }
];
this.loadFromAuth();
}
loadFromAuth() {
if (window.PvZAuth) {
const uData = window.PvZAuth.getUserData();
if (uData.zombieLab) {
const zl = uData.zombieLab;
if (zl.levels) {
for (const k in this.levels) {
if (zl.levels[k]) this.levels[k] = Math.min(3, Math.max(1, Number(zl.levels[k])));
}
}
if (zl.unlockedStage) {
this.unlockedStage = Math.max(1, Math.min(5, Number(zl.unlockedStage)));
}
}
}
}
saveToAuth() {
if (window.PvZAuth && !window.PvZAuth.isGuest()) {
window.PvZAuth.saveUserData({
zombieLab: {
levels: { ...this.levels },
unlockedStage: this.unlockedStage
}
});
}
}
getZombieLevel(type) {
return this.levels[type] || 1;
}
canUpgrade(type) {
const curLevel = this.getZombieLevel(type);
if (curLevel >= 3) return false;
const def = this.zombies[type];
if (!def) return false;
const nextTier = def.tiers[curLevel + 1];
if (!nextTier) return false;
const cost = nextTier.cost;
if (cost.coins && (!this.economy || this.economy.totalCoins < cost.coins)) return false;
if (this.workshop) {
if (cost.scrapMetal && (this.workshop.materials.scrapMetal || 0) < cost.scrapMetal) return false;
if (cost.bioHumus && (this.workshop.materials.bioHumus || 0) < cost.bioHumus) return false;
if (cost.crystals && (this.workshop.materials.crystals || 0) < cost.crystals) return false;
}
return true;
}
upgradeZombie(type) {
const curLevel = this.getZombieLevel(type);
if (curLevel >= 3) return { success: false, message: 'Максимальный уровень мутации!' };
const def = this.zombies[type];
if (!def) return { success: false, message: 'Неизвестный зомби' };
const nextTier = def.tiers[curLevel + 1];
if (!nextTier) return { success: false, message: 'Нет следующего уровня' };
const cost = nextTier.cost;
if (cost.coins && (!this.economy || this.economy.totalCoins < cost.coins)) {
return { success: false, message: 'Недостаточно монет' };
}
if (this.workshop) {
if (cost.scrapMetal && (this.workshop.materials.scrapMetal || 0) < cost.scrapMetal) {
return { success: false, message: 'Недостаточно металлолома ⚙️' };
}
if (cost.bioHumus && (this.workshop.materials.bioHumus || 0) < cost.bioHumus) {
return { success: false, message: 'Недостаточно био-гумуса 🧪' };
}
if (cost.crystals && (this.workshop.materials.crystals || 0) < cost.crystals) {
return { success: false, message: 'Недостаточно кристаллов 💎' };
}
// Deduct materials
if (cost.scrapMetal) this.workshop.materials.scrapMetal -= cost.scrapMetal;
if (cost.bioHumus) this.workshop.materials.bioHumus -= cost.bioHumus;
if (cost.crystals) this.workshop.materials.crystals -= cost.crystals;
this.workshop.saveToAuth();
}
if (cost.coins) {
this.economy.totalCoins -= cost.coins;
this.economy.saveProfileData();
}
this.levels[type] = curLevel + 1;
this.saveToAuth();
if (window.PvZAudio) window.PvZAudio.playWin();
return {
success: true,
newLevel: this.levels[type],
message: `🎉 ${def.name} мутировал до Ур. ${this.levels[type]} («${nextTier.title}»)!`
};
}
unlockNextStage(completedStage) {
if (completedStage >= this.unlockedStage && this.unlockedStage < 5) {
this.unlockedStage = completedStage + 1;
this.saveToAuth();
return true;
}
return false;
}
};