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:
@@ -0,0 +1,393 @@
|
||||
// Wave Management, Level Progression & Game Modes System
|
||||
window.WaveSystem = class WaveSystem {
|
||||
constructor() {
|
||||
this.mode = 'adventure'; // 'adventure', 'survival', 'bowling', 'izombie'
|
||||
this.level = 1;
|
||||
this.biome = 'day';
|
||||
|
||||
this.currentWave = 0;
|
||||
this.totalWaves = 4;
|
||||
this.waveTimer = 0;
|
||||
this.spawnQueue = [];
|
||||
this.waveState = 'starting';
|
||||
this.bannerText = '';
|
||||
this.bannerTimer = 0;
|
||||
this.isVictory = false;
|
||||
|
||||
// Endless Survival tracking
|
||||
this.survivalFlags = 0;
|
||||
}
|
||||
|
||||
setLevel(level = 1, mode = 'adventure') {
|
||||
this.level = level;
|
||||
this.mode = mode;
|
||||
|
||||
if (mode === 'bowling') {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = 5;
|
||||
} else if (mode === 'izombie') {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = 1;
|
||||
} else if (mode === 'boss') {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 1;
|
||||
} else if (mode === 'bossrush') {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 3;
|
||||
} else if (mode === 'roguelike') {
|
||||
this.totalWaves = 2;
|
||||
this.biome = (level <= 2) ? 'day' : (level <= 4 ? 'night' : (level <= 6 ? 'pool' : 'roof'));
|
||||
} else if (mode === 'survival') {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = 999;
|
||||
} else {
|
||||
// Adventure Campaign Mode with CampaignSystem
|
||||
if (window.CampaignSystem) {
|
||||
const campLvl = window.CampaignSystem.getLevel(level);
|
||||
this.biome = campLvl.biome || 'day';
|
||||
this.totalWaves = campLvl.waves || 4;
|
||||
if (campLvl.isBoss) {
|
||||
this.mode = 'boss';
|
||||
}
|
||||
} else {
|
||||
if (level <= 5) {
|
||||
this.biome = 'day';
|
||||
this.totalWaves = (level === 1) ? 3 : 4;
|
||||
} else if (level <= 10) {
|
||||
this.biome = 'night';
|
||||
this.totalWaves = 4;
|
||||
} else if (level <= 15) {
|
||||
this.biome = 'pool';
|
||||
this.totalWaves = 4;
|
||||
} else if (level <= 20) {
|
||||
this.biome = 'fog';
|
||||
this.totalWaves = 4;
|
||||
} else if (level < 25) {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 4;
|
||||
} else {
|
||||
this.biome = 'roof';
|
||||
this.totalWaves = 1;
|
||||
this.mode = 'boss';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentWave = 0;
|
||||
this.waveTimer = 0;
|
||||
this.spawnQueue = [];
|
||||
this.waveState = 'starting';
|
||||
this.survivalFlags = 0; // reset per-game, not per-session
|
||||
|
||||
let advBanner = `УРОВЕНЬ ${this.level}`;
|
||||
if (window.CampaignSystem && this.mode === 'adventure') {
|
||||
const campFmt = window.CampaignSystem.formatLevel(this.level);
|
||||
advBanner = `${campFmt.worldIcon} ЭТАП ${campFmt.code}: ${campFmt.title.toUpperCase()}`;
|
||||
}
|
||||
|
||||
this.bannerText = this.mode === 'bowling'
|
||||
? '🎳 БОУЛИНГ СТЕНООРЕХАМИ!'
|
||||
: (this.mode === 'izombie'
|
||||
? '🧠 СЪЕШЬТЕ ВСЕ ПОДСОЛНУХИ!'
|
||||
: (this.mode === 'bossrush'
|
||||
? '⚡ BOSS RUSH: ЧЕРЕДА БОССОВ!'
|
||||
: (this.mode === 'boss'
|
||||
? '👑 БИТВА: ДОКТОР ЗОМБОСС!'
|
||||
: (this.mode === 'roguelike'
|
||||
? `🎲 РОГАЛИК: ЭТАЖ ${this.level}`
|
||||
: (this.mode === 'survival' ? '⚔️ БЕСКОНЕЧНОЕ ВЫЖИВАНИЕ' : advBanner)))));
|
||||
this.bannerTimer = 3.5;
|
||||
this.isVictory = false;
|
||||
}
|
||||
|
||||
startNextWave(gameState) {
|
||||
this.currentWave++;
|
||||
this.waveState = 'spawning';
|
||||
|
||||
if (this.mode === 'boss') {
|
||||
this.bannerText = '👑 ДОКТОР ЗОМБОСС АТАКУЕТ!';
|
||||
this.bannerTimer = 3.5;
|
||||
if (gameState.boss) {
|
||||
gameState.boss.startFight();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'bossrush') {
|
||||
if (this.currentWave === 1) {
|
||||
this.bannerText = '⚡ ВОЛНА 1: ОРДА ТЯЖЕЛОВЕСОВ И СПРИНТЕРОВ!';
|
||||
this.bannerTimer = 3.5;
|
||||
this.spawnQueue = [];
|
||||
const count = 7;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['football', 'buckethead', 'conehead'];
|
||||
const t = types[i % types.length];
|
||||
this.spawnQueue.push({
|
||||
delay: 2.0 + i * 2.2,
|
||||
type: t,
|
||||
row: i % gameState.grid.rows
|
||||
});
|
||||
}
|
||||
} else if (this.currentWave === 2) {
|
||||
this.bannerText = '⚡ ВОЛНА 2: ЭЛИТНЫЙ ЗОМБИ-СПЕЦНАЗ!';
|
||||
this.bannerTimer = 3.5;
|
||||
this.spawnQueue = [];
|
||||
const count = 9;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['newspaper', 'polevaulter', 'buckethead', 'football'];
|
||||
const t = types[i % types.length];
|
||||
this.spawnQueue.push({
|
||||
delay: 1.8 + i * 1.8,
|
||||
type: t,
|
||||
row: (i * 2) % gameState.grid.rows
|
||||
});
|
||||
}
|
||||
} else if (this.currentWave === 3) {
|
||||
this.bannerText = '👑 ФИНАЛЬНЫЙ БОСС: Д-Р ЗОМБОСС & ЗОМБОТ!';
|
||||
this.bannerTimer = 4.0;
|
||||
if (gameState.boss) {
|
||||
gameState.boss.startFight();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'bowling') {
|
||||
this.bannerText = `ВОЛНА ${this.currentWave} / ${this.totalWaves}`;
|
||||
this.bannerTimer = 2.5;
|
||||
const count = 4 + this.currentWave * 2;
|
||||
this.spawnQueue = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const types = ['normal', 'conehead', 'newspaper', 'polevaulter', 'buckethead'];
|
||||
const t = types[Math.floor(Math.random() * Math.min(types.length, 2 + this.currentWave))];
|
||||
this.spawnQueue.push({
|
||||
delay: 2.0 + i * 2.5,
|
||||
type: t,
|
||||
row: Math.floor(Math.random() * gameState.grid.rows)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'survival') {
|
||||
this.survivalFlags++;
|
||||
this.bannerText = `🚩 ФЛАГ ${this.survivalFlags}!`;
|
||||
this.bannerTimer = 3.0;
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
|
||||
const waveSize = 8 + this.survivalFlags * 3;
|
||||
this.spawnQueue = [];
|
||||
const types = ['normal', 'conehead', 'buckethead', 'polevaulter', 'newspaper', 'football'];
|
||||
for (let i = 0; i < waveSize; i++) {
|
||||
this.spawnQueue.push({
|
||||
delay: 1.5 + i * (22 / waveSize),
|
||||
type: types[Math.floor(Math.random() * types.length)],
|
||||
row: Math.floor(Math.random() * gameState.grid.rows)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'roguelike') {
|
||||
this.bannerText = `🎲 ЭТАЖ ${this.level} - ВОЛНА ${this.currentWave} / ${this.totalWaves}`;
|
||||
this.bannerTimer = 2.8;
|
||||
const count = 3 + this.level * 2 + this.currentWave * 2;
|
||||
this.spawnQueue = [];
|
||||
const types = ['normal', 'conehead', 'newspaper', 'polevaulter', 'buckethead', 'football'];
|
||||
const maxTypeIndex = Math.min(types.length - 1, Math.floor(this.level / 2) + this.currentWave);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = types[Math.floor(Math.random() * (maxTypeIndex + 1))];
|
||||
this.spawnQueue.push({
|
||||
delay: 1.5 + i * (14 / count),
|
||||
type: t,
|
||||
row: this.biome === 'pool' && Math.random() < 0.35 ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState)
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Adventure Campaign Waves
|
||||
if (this.currentWave === 1) {
|
||||
this.bannerText = 'ПЕРВЫЕ ЗОМБИ...';
|
||||
this.bannerTimer = 3.0;
|
||||
this.spawnQueue = [
|
||||
{ delay: 3.0, type: 'normal', row: this.randomRow(gameState) },
|
||||
{ delay: 8.0, type: 'normal', row: this.randomRow(gameState) },
|
||||
{ delay: 14.0, type: 'conehead', row: this.randomRow(gameState) },
|
||||
{ delay: 19.0, type: 'normal', row: this.randomRow(gameState) }
|
||||
];
|
||||
} else if (this.currentWave === 2) {
|
||||
this.bannerText = 'ВОЛНА 2';
|
||||
this.bannerTimer = 2.5;
|
||||
const isWaterBiome = this.biome === 'pool' || this.biome === 'fog';
|
||||
const typeList = isWaterBiome
|
||||
? ['normal', 'duckytube', 'conehead', 'duckytube', 'polevaulter']
|
||||
: ['normal', 'newspaper', 'polevaulter', 'conehead', 'normal'];
|
||||
this.spawnQueue = typeList.map((t, idx) => ({
|
||||
delay: 2.5 + idx * 3.5,
|
||||
type: t,
|
||||
row: t === 'duckytube' ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState)
|
||||
}));
|
||||
} else if (this.currentWave === 3) {
|
||||
this.bannerText = '⚠️ БОЛЬШАЯ ВОЛНА ЗОМБИ!';
|
||||
this.bannerTimer = 4.0;
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
|
||||
const isWaterBiome = this.biome === 'pool' || this.biome === 'fog';
|
||||
this.spawnQueue = [
|
||||
{ delay: 2.0, type: 'flag', row: isWaterBiome ? 1 : 2 },
|
||||
{ delay: 2.5, type: 'conehead', row: 0 },
|
||||
{ delay: 2.8, type: isWaterBiome ? 'duckytube' : 'newspaper', row: isWaterBiome ? 2 : 1 },
|
||||
{ delay: 3.2, type: 'football', row: isWaterBiome ? 4 : 3 },
|
||||
{ delay: 3.5, type: isWaterBiome ? 'duckytube' : 'polevaulter', row: isWaterBiome ? 3 : 4 },
|
||||
{ delay: 7.0, type: 'buckethead', row: this.randomRow(gameState) },
|
||||
{ delay: 9.0, type: isWaterBiome ? 'duckytube' : 'normal', row: isWaterBiome ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState) },
|
||||
{ delay: 12.0, type: 'conehead', row: this.randomRow(gameState) }
|
||||
];
|
||||
|
||||
// Night mode: Zombies rise from gravestones!
|
||||
if (this.biome === 'night') {
|
||||
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;
|
||||
gameState.zombies.push(z);
|
||||
gameState.combat.createExplosionParticles(bounds.centerX, bounds.centerY);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
} else if (this.currentWave >= 4) {
|
||||
this.bannerText = '☠️ ПОСЛЕДНЯЯ ВОЛНА!';
|
||||
this.bannerTimer = 4.5;
|
||||
if (window.PvZAudio) window.PvZAudio.playHugeWave();
|
||||
|
||||
const isWaterBiome = this.biome === 'pool' || this.biome === 'fog';
|
||||
this.spawnQueue = [
|
||||
{ delay: 2.0, type: 'flag', row: isWaterBiome ? 1 : 2 },
|
||||
{ delay: 2.4, type: 'football', row: 0 },
|
||||
{ delay: 2.8, type: isWaterBiome ? 'duckytube' : 'buckethead', row: isWaterBiome ? 2 : 1 },
|
||||
{ delay: 3.2, type: 'polevaulter', row: isWaterBiome ? 4 : 2 },
|
||||
{ delay: 3.5, type: isWaterBiome ? 'duckytube' : 'newspaper', row: isWaterBiome ? 3 : 3 },
|
||||
{ delay: 3.8, type: 'conehead', row: isWaterBiome ? 5 : 4 },
|
||||
{ delay: 6.5, type: 'buckethead', row: this.randomRow(gameState) },
|
||||
{ delay: 8.0, type: 'football', row: this.randomRow(gameState) },
|
||||
{ delay: 10.0, type: isWaterBiome ? 'duckytube' : 'conehead', row: isWaterBiome ? (Math.random() > 0.5 ? 2 : 3) : this.randomRow(gameState) },
|
||||
{ delay: 12.0, type: 'normal', row: this.randomRow(gameState) }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
randomRow(gameState) {
|
||||
return Math.floor(Math.random() * gameState.grid.rows);
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
if (this.bannerTimer > 0) {
|
||||
this.bannerTimer -= dt;
|
||||
}
|
||||
|
||||
if (this.mode === 'izombie') return;
|
||||
|
||||
if (this.waveState === 'starting') {
|
||||
this.waveTimer += dt;
|
||||
if (this.waveTimer >= 3.5) {
|
||||
this.waveTimer = 0;
|
||||
this.startNextWave(gameState);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === 'boss') return; // Boss mode fight is updated by boss system
|
||||
|
||||
if (this.waveState === 'spawning') {
|
||||
this.waveTimer += dt;
|
||||
|
||||
for (let i = this.spawnQueue.length - 1; i >= 0; i--) {
|
||||
const item = this.spawnQueue[i];
|
||||
if (this.waveTimer >= item.delay) {
|
||||
gameState.spawnZombie(item.row, item.type);
|
||||
this.spawnQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// In bossrush wave 3, victory is triggered when boss is defeated, not immediately
|
||||
const isBossRushFight = this.mode === 'bossrush' && this.currentWave === 3;
|
||||
if (this.spawnQueue.length === 0 && gameState.zombies.length === 0 && !isBossRushFight) {
|
||||
if (this.currentWave >= this.totalWaves && this.mode !== 'survival') {
|
||||
this.waveState = 'completed';
|
||||
this.isVictory = true;
|
||||
gameState.triggerVictory();
|
||||
} else {
|
||||
this.waveState = 'waiting';
|
||||
this.waveTimer = 0;
|
||||
}
|
||||
}
|
||||
} else if (this.waveState === 'waiting') {
|
||||
this.waveTimer += dt;
|
||||
if (this.waveTimer >= 4.0) {
|
||||
this.waveTimer = 0;
|
||||
this.startNextWave(gameState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
// 1. Progress Bar in Top Right
|
||||
const barX = 685;
|
||||
const barY = 22;
|
||||
const barW = 115;
|
||||
const barH = 22;
|
||||
|
||||
ctx.fillStyle = '#212121';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX, barY, barW, barH, 4);
|
||||
ctx.fill();
|
||||
|
||||
const progress = this.mode === 'survival'
|
||||
? 1.0
|
||||
: Math.min(1, (this.currentWave - 1 + (this.waveState === 'waiting' || this.waveState === 'completed' ? 1 : 0.5)) / this.totalWaves);
|
||||
|
||||
ctx.fillStyle = '#4caf50';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX + barW * (1 - progress), barY + 2, barW * progress - 2, barH - 4, 3);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#8d6e63';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(barX, barY, barW, barH);
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const label = this.mode === 'survival'
|
||||
? `ФЛАГ ${this.survivalFlags}`
|
||||
: (this.mode === 'roguelike' ? `ЭТАЖ ${this.level} (${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves})` : `ВОЛНА ${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves}`);
|
||||
ctx.fillText(label, barX + barW / 2, barY + 16);
|
||||
|
||||
// 2. Center Banner Display
|
||||
if (this.bannerTimer > 0 && this.bannerText) {
|
||||
ctx.save();
|
||||
const alpha = Math.min(1, this.bannerTimer);
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
|
||||
ctx.fillRect(0, 240, 900, 70);
|
||||
|
||||
ctx.fillStyle = this.bannerText.includes('БОЛЬШАЯ') || this.bannerText.includes('ПОСЛЕДНЯЯ')
|
||||
? '#ff1744'
|
||||
: '#ffeb3b';
|
||||
ctx.font = 'bold 30px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowColor = '#000';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillText(this.bannerText, 450, 285);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user