Files
ai-qpid-pvz/game/js/systems/wave.js
T

637 lines
24 KiB
JavaScript

// 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';
}
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;
} 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 (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;
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);
}
}
this.initialWaveZombies = Math.max(1, this.spawnQueue.length);
} 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) }
];
this.initialWaveZombies = Math.max(1, this.spawnQueue.length);
}
if (!this.initialWaveZombies) {
this.initialWaveZombies = Math.max(1, this.spawnQueue.length);
}
}
randomRow(gameState) {
return Math.floor(Math.random() * gameState.grid.rows);
}
getFlagWaves() {
if (this.mode === 'survival' || this.mode === 'izombie') return [];
const flags = [];
if (this.totalWaves >= 4) {
flags.push({ wave: this.totalWaves - 1, type: 'huge' });
flags.push({ wave: this.totalWaves, type: 'final' });
} else if (this.totalWaves === 3) {
flags.push({ wave: 2, type: 'huge' });
flags.push({ wave: 3, type: 'final' });
} else if (this.totalWaves >= 1) {
flags.push({ wave: this.totalWaves, type: 'final' });
}
return flags;
}
update(dt, gameState) {
if (this.bannerTimer > 0) {
this.bannerTimer -= dt;
}
if (this.mode === 'izombie') return;
// Smooth wave progress calculation
const total = Math.max(1, this.totalWaves);
let currentFrac = 0;
if (this.waveState === 'waiting' || this.waveState === 'completed') {
currentFrac = 1.0;
} else if (this.waveState === 'spawning') {
const initial = Math.max(1, this.initialWaveZombies || 1);
const remainingInQueue = this.spawnQueue.length;
const spawnedFrac = Math.max(0, 1 - (remainingInQueue / initial));
const aliveCount = (gameState && gameState.zombies) ? gameState.zombies.length : 0;
const defeatFrac = aliveCount > 0 ? 0.5 : 1.0;
currentFrac = Math.min(0.95, spawnedFrac * 0.7 + defeatFrac * 0.25);
} else {
currentFrac = 0.05;
}
const targetProgress = this.mode === 'survival'
? 1.0
: Math.min(1, Math.max(0, (Math.max(0, this.currentWave - 1) + currentFrac) / total));
if (this.smoothProgress === undefined) this.smoothProgress = 0;
this.smoothProgress += (targetProgress - this.smoothProgress) * Math.min(1, dt * 3.5);
if (this.waveState === 'starting') {
this.waveTimer += dt;
if (this.waveTimer >= 3.5) {
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;
this.smoothProgress = 1.0;
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) {
if (this.mode === 'izombie') return;
// 1. Visual Wave Progress Bar in Top Right HUD
const barX = 680;
const barY = 16;
const barW = 205;
const barH = 24;
// Outer frame & drop shadow
ctx.save();
ctx.fillStyle = '#1e1008';
ctx.beginPath();
ctx.roundRect(barX, barY, barW, barH, 5);
ctx.fill();
// Inset track
ctx.fillStyle = '#110b06';
ctx.beginPath();
ctx.roundRect(barX + 2, barY + 2, barW - 4, barH - 4, 3);
ctx.fill();
// Progress fill with PvZ gradient
const progress = this.mode === 'survival' ? 1.0 : Math.min(1, Math.max(0, this.smoothProgress || 0));
const fillW = Math.max(0, (barW - 4) * progress);
if (fillW > 0) {
const grad = ctx.createLinearGradient(barX + 2, barY, barX + 2 + fillW, barY);
const isFinalOrHuge = this.currentWave >= this.totalWaves || (this.totalWaves >= 4 && this.currentWave === this.totalWaves - 1);
if (isFinalOrHuge) {
grad.addColorStop(0, '#e65100');
grad.addColorStop(0.5, '#f57c00');
grad.addColorStop(1, '#ffd54f');
} else {
grad.addColorStop(0, '#1b5e20');
grad.addColorStop(0.5, '#388e3c');
grad.addColorStop(1, '#76ff03');
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(barX + 2, barY + 2, fillW, barH - 4, 3);
ctx.fill();
}
// Flag Markers on the Progress Bar
const flagWaves = this.getFlagWaves();
for (const flag of flagWaves) {
const flagRatio = flag.wave / this.totalWaves;
const fx = barX + 2 + (barW - 4) * flagRatio;
const isReached = this.currentWave >= flag.wave;
// Flag pole
ctx.strokeStyle = '#9e9e9e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(fx, barY + 1);
ctx.lineTo(fx, barY + barH - 2);
ctx.stroke();
// Flag banner
ctx.save();
if (flag.type === 'final') {
// Final wave skull / red flag
ctx.fillStyle = isReached ? '#d50000' : '#8d6e63';
ctx.beginPath();
ctx.moveTo(fx, barY + 3);
ctx.lineTo(fx - 12, barY + 7);
ctx.lineTo(fx, barY + 12);
ctx.closePath();
ctx.fill();
// Skull dot / marker
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('☠️', fx - 1, barY - 2);
} else {
// Huge wave orange/red flag
ctx.fillStyle = isReached ? '#ff3d00' : '#8d6e63';
ctx.beginPath();
ctx.moveTo(fx, barY + 3);
ctx.lineTo(fx - 10, barY + 7);
ctx.lineTo(fx, barY + 11);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('🚩', fx - 1, barY - 2);
}
ctx.restore();
}
// Border highlight
ctx.strokeStyle = '#8d6e63';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(barX, barY, barW, barH, 5);
ctx.stroke();
// Moving Zombie Head icon on progress tip
if (this.mode !== 'survival') {
const zx = barX + 2 + fillW;
const zy = barY + barH / 2;
ctx.save();
// Green zombie head circle
ctx.fillStyle = '#689f38';
ctx.strokeStyle = '#33691e';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(zx, zy, 7, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Eyes
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(zx - 2.5, zy - 1.5, 2, 0, Math.PI * 2);
ctx.arc(zx + 2.5, zy - 1.5, 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000000';
ctx.beginPath();
ctx.arc(zx - 2.5, zy - 1.5, 1, 0, Math.PI * 2);
ctx.arc(zx + 2.5, zy - 1.5, 0.8, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// Text Label inside progress bar
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.shadowColor = '#000000';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
let label = '';
if (this.mode === 'survival') {
label = `🚩 ВЫЖИВАНИЕ: ФЛАГ ${this.survivalFlags}`;
} else if (this.mode === 'boss' || this.mode === 'bossrush') {
label = `👑 БОСС (${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves})`;
} else if (this.mode === 'roguelike') {
label = `🎲 ЭТАЖ ${this.level} (${Math.min(this.currentWave, this.totalWaves)}/${this.totalWaves})`;
} else if (this.currentWave >= this.totalWaves && this.totalWaves > 1) {
label = `☠️ ПОСЛЕДНЯЯ ВОЛНА (${this.totalWaves}/${this.totalWaves})`;
} else if (this.totalWaves >= 4 && this.currentWave === this.totalWaves - 1) {
label = `⚠️ БОЛЬШАЯ ВОЛНА (${this.currentWave}/${this.totalWaves})`;
} else {
label = `ВОЛНА ${Math.min(this.currentWave, this.totalWaves)} / ${this.totalWaves}`;
}
ctx.fillText(label, barX + barW / 2, barY + 16);
ctx.restore();
// 2. Center Banner Display
if (this.bannerTimer > 0 && this.bannerText) {
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();
}
}
};