feat(gameplay): add crazy dave casino and bank, and zombie mutation lab
This commit is contained in:
+238
-20
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user