// Particle Object Pool to eliminate GC churn during large waves window.ParticlePool = class ParticlePool { constructor(initialCapacity = 150) { this.pool = []; for (let i = 0; i < initialCapacity; i++) { this.pool.push({ x: 0, y: 0, vx: 0, vy: 0, color: '#ffffff', size: 3, life: 0, maxLife: 0 }); } } get(x, y, vx, vy, color = '#8bc34a', size = 3, life = 0.35) { const p = this.pool.length > 0 ? this.pool.pop() : { x: 0, y: 0, vx: 0, vy: 0, color: '#ffffff', size: 3, life: 0, maxLife: 0 }; p.x = x; p.y = y; p.vx = vx; p.vy = vy; p.color = color; p.size = size; p.life = life; p.maxLife = life; return p; } release(p) { if (!p) return; p.life = 0; this.pool.push(p); } releaseAll(particles) { if (!Array.isArray(particles)) return; for (let i = 0; i < particles.length; i++) { this.release(particles[i]); } } clear() { this.pool = []; } }; // Zombie Decapitation Flying Head Particle window.ZombieHeadParticle = class ZombieHeadParticle { constructor(x, y, options = {}) { this.x = x; this.y = y; this.vx = (Math.random() * 50 + 40) * (Math.random() < 0.5 ? 1 : -0.7); this.vy = -(Math.random() * 60 + 130); this.gravity = 400; this.rotation = 0; this.rotSpeed = (Math.random() * 4 + 4) * (this.vx >= 0 ? 1 : -1); this.groundY = y + 62; this.bounces = 2; this.life = 2.2; this.maxLife = 2.2; this.type = options.type || 'normal'; this.isAngry = !!options.isAngry; this.isSlow = !!options.isSlow; this.cheats = options.cheats || null; } update(dt) { this.life -= dt; if (this.life <= 0) return; this.vy += this.gravity * dt; this.x += this.vx * dt; this.y += this.vy * dt; this.rotation += this.rotSpeed * dt; if (this.y >= this.groundY) { this.y = this.groundY; if (this.bounces > 0) { this.bounces--; this.vy = -this.vy * 0.42; this.vx *= 0.6; this.rotSpeed *= 0.5; } else { this.vy = 0; this.vx = 0; this.rotSpeed = 0; } } } draw(ctx) { if (this.life <= 0) return; ctx.save(); const alpha = this.life < 0.6 ? Math.max(0, this.life / 0.6) : 1; // Ground shadow const distToGround = Math.max(0, this.groundY - this.y); if (distToGround < 65) { ctx.save(); ctx.translate(this.x, this.groundY + 12); ctx.fillStyle = `rgba(0,0,0,${0.25 * (1 - distToGround / 65) * alpha})`; ctx.beginPath(); ctx.ellipse(0, 0, 14, 5, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } ctx.translate(this.x, this.y); ctx.rotate(this.rotation); ctx.globalAlpha = alpha; let skinColor = this.isAngry ? '#b71c1c' : '#809973'; if (this.isSlow) skinColor = '#81d4fa'; // Head base ctx.fillStyle = skinColor; ctx.beginPath(); ctx.roundRect(-14, -16, 28, 30, [10, 10, 6, 6]); ctx.fill(); // Eyes ctx.fillStyle = this.isAngry ? '#ff5252' : '#fff9c4'; ctx.beginPath(); ctx.arc(-8, -4, 4.5, 0, Math.PI * 2); ctx.arc(3, -4, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(-9, -4, 2, 0, Math.PI * 2); ctx.arc(2, -4, 2, 0, Math.PI * 2); ctx.fill(); // Open mouth / teeth ctx.fillStyle = '#212121'; ctx.fillRect(-10, 6, 18, 5); ctx.fillStyle = '#fff'; ctx.fillRect(-9, 6, 3, 3); ctx.fillRect(-4, 6, 3, 3); ctx.fillRect(1, 8, 3, 3); // Cheats if (this.cheats) { if (this.cheats.future) { ctx.fillStyle = '#00e5ff'; ctx.fillRect(-12, -7, 22, 6); } if (this.cheats.mustache) { ctx.fillStyle = '#3e2723'; ctx.beginPath(); ctx.moveTo(-11, 4); ctx.quadraticCurveTo(-4, 0, 0, 3); ctx.quadraticCurveTo(4, 0, 9, 4); ctx.quadraticCurveTo(0, 8, -11, 4); ctx.fill(); } } // Hat / Helmet if (this.type === 'conehead') { ctx.fillStyle = '#ff6d00'; ctx.beginPath(); ctx.moveTo(-14, -14); ctx.lineTo(14, -14); ctx.lineTo(3, -48); ctx.lineTo(-3, -48); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.moveTo(-8, -26); ctx.lineTo(8, -26); ctx.lineTo(6, -34); ctx.lineTo(-6, -34); ctx.closePath(); ctx.fill(); } else if (this.type === 'buckethead') { ctx.fillStyle = '#90a4ae'; ctx.beginPath(); ctx.moveTo(-16, -14); ctx.lineTo(16, -14); ctx.lineTo(13, -44); ctx.lineTo(-13, -44); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#546e7a'; ctx.lineWidth = 2; ctx.stroke(); } else if (this.type === 'football') { ctx.fillStyle = '#b71c1c'; ctx.beginPath(); ctx.arc(0, -10, 18, Math.PI, 0); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.fillRect(-3, -28, 6, 18); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(-12, -4); ctx.lineTo(-2, 4); ctx.lineTo(8, -4); ctx.stroke(); } ctx.restore(); } }; // Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops) window.CombatSystem = class CombatSystem { constructor() { this.particlePool = new window.ParticlePool(150); this.particles = []; this.heads = []; } reset() { if (this.particlePool) { this.particlePool.releaseAll(this.particles); } this.particles = []; this.heads = []; } spawnDecapitatedHead(x, y, type = 'normal', isAngry = false, isSlow = false, cheats = null) { this.heads.push(new window.ZombieHeadParticle(x, y, { type, isAngry, isSlow, cheats })); this.createSplash(x, y + 10, '#689f38', 5); } createSplash(x, y, color = '#8bc34a', count = 7) { for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 40 + Math.random() * 80; this.particles.push(this.particlePool.get( x, y, Math.cos(angle) * speed, Math.sin(angle) * speed, color, 3 + Math.random() * 3, 0.35 )); } } createExplosionParticles(x, y) { const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242']; for (let i = 0; i < 35; i++) { const angle = Math.random() * Math.PI * 2; const speed = 50 + Math.random() * 200; this.particles.push(this.particlePool.get( x, y, Math.cos(angle) * speed, Math.sin(angle) * speed, colors[Math.floor(Math.random() * colors.length)], 5 + Math.random() * 8, 0.65 )); } } update(dt, gameState) { // 1. Update Particles in-place (eliminating GC allocations) let writeIdx = 0; for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; if (p.life > 0) { this.particles[writeIdx++] = p; } else { this.particlePool.release(p); } } this.particles.length = writeIdx; // 1.5 Update Decapitated Heads let headWriteIdx = 0; for (let i = 0; i < this.heads.length; i++) { const h = this.heads[i]; h.update(dt); if (h.life > 0) { this.heads[headWriteIdx++] = h; } } this.heads.length = headWriteIdx; // 2. Projectiles vs Zombies for (const proj of gameState.projectiles) { if (proj.toRemove) continue; for (const zombie of gameState.zombies) { if (zombie.toRemove || zombie.health <= 0) continue; // Check lane alignment const isSameLane = proj.type === 'bowling' ? Math.abs(proj.y - zombie.y) < 45 : proj.row === zombie.row; if (isSameLane) { if (proj.x >= zombie.x - 28 && proj.x <= zombie.x + 28) { // Piercing fume spore handles each zombie once if (proj.pierces) { if (!proj.pierceHits.has(zombie)) { proj.pierceHits.add(zombie); zombie.takeDamage(proj.damage, false); this.createSplash(proj.x, proj.y, '#ce93d8'); if (zombie.health <= 0) this.handleZombieDeath(zombie, gameState); } continue; } // Bowling Ball logic (Wall-nut strike & ricochet) if (proj.type === 'bowling') { zombie.takeDamage(proj.damage); this.createSplash(proj.x, proj.y, '#8d6e63', 12); if (window.PvZAudio) window.PvZAudio.playStrike(); proj.hitCount = (proj.hitCount || 0) + 1; if (proj.hitCount >= 5 && gameState.achievements) { gameState.achievements.trigger('bowling_strike'); } if (zombie.health <= 0) this.handleZombieDeath(zombie, gameState); // Ricochet vertically if (proj.vy === 0) { proj.vy = (proj.y < 300) ? 180 : -180; } else { proj.vy = -proj.vy; } break; } // Lobbed Cabbage & Melon if (proj.isLobbed) { zombie.takeDamage(proj.damage); this.createSplash(proj.x, proj.y, proj.type === 'melon' ? '#2e7d32' : '#81c784', 12); if (window.PvZAudio) window.PvZAudio.playHit(); if (zombie.health <= 0) this.handleZombieDeath(zombie, gameState); // Melon splash damage to adjacent rows if (proj.splashDamage > 0) { for (const otherZ of gameState.zombies) { if (otherZ !== zombie && !otherZ.toRemove && otherZ.health > 0) { if (Math.abs(otherZ.row - proj.row) <= 1 && Math.abs(otherZ.x - proj.x) < 65) { otherZ.takeDamage(proj.splashDamage); if (otherZ.health <= 0) this.handleZombieDeath(otherZ, gameState); } } } } proj.toRemove = true; break; } // Standard Pea / Snowpea / Plasma / Firepea zombie.takeDamage(proj.damage, proj.slows); if (proj.slows && gameState && gameState.achievements) { gameState.achievements.trigger('ice_age'); } if (proj.freezes) { zombie.slowTimer = 7.0; // Deep freeze } if (proj.pierces) { proj.pierceHits.add(zombie); if (proj.pierceHits.size >= proj.maxPierces) { proj.toRemove = true; } } else { proj.toRemove = true; } // Firepea splash damage if (proj.type === 'firepea') { for (const otherZ of gameState.zombies) { if (otherZ !== zombie && otherZ.row === proj.row && Math.abs(otherZ.x - proj.x) < 55) { otherZ.takeDamage(proj.damage * 0.5); if (otherZ.health <= 0) this.handleZombieDeath(otherZ, gameState); } } this.createExplosionParticles(proj.x, proj.y); } if (zombie.health <= 0) { this.handleZombieDeath(zombie, gameState); } const splashColor = proj.type === 'plasma' ? '#00e5ff' : (proj.type === 'firepea' ? '#ff9800' : (proj.slows ? '#80deea' : '#8bc34a')); this.createSplash(proj.x, proj.y, splashColor); if (window.PvZAudio) window.PvZAudio.playHit(); if (!proj.pierces || proj.toRemove) break; } } } } // 3. Zombies vs Plants for (const zombie of gameState.zombies) { if (zombie.toRemove || zombie.health <= 0) continue; let touchingPlant = null; for (const plant of gameState.plants) { if (plant.toRemove || plant.health <= 0) continue; if (plant.row === zombie.row) { const dist = zombie.x - plant.x; if (dist >= -5 && dist <= 28) { touchingPlant = plant; // Tier 3 Wall-nut recoil thorns damage! if (plant.recoilDps) { zombie.takeDamage(plant.recoilDps * dt); if (zombie.health <= 0) { this.handleZombieDeath(zombie, gameState); } } break; } } } zombie.targetPlant = touchingPlant; } // 4. Breach Check for (const zombie of gameState.zombies) { if (zombie.toRemove || zombie.health <= 0) continue; if (zombie.x < 35) { if (gameState.currentMode === 'izombie') { // In I, Zombie, reaching the left end means zombie reached the house / brains! zombie.toRemove = true; if (window.PvZAudio) window.PvZAudio.playGulp(); } else { gameState.triggerGameOver(); break; } } } // 5. Projectiles vs Dr. Zomboss if (gameState.boss && gameState.boss.isActive) { for (const proj of gameState.projectiles) { if (!proj.toRemove && proj.x >= gameState.boss.x) { gameState.boss.takeDamage(proj.damage); this.createSplash(proj.x, proj.y, '#ffd54f', 8); proj.toRemove = true; } } } } handleZombieDeath(zombie, gameState) { if (!zombie.headSpawned) { zombie.headSpawned = true; this.spawnDecapitatedHead( zombie.x, zombie.y - 24, zombie.type, zombie.isAngry, zombie.slowTimer > 0 || (zombie.freezeTimer && zombie.freezeTimer > 0) || zombie.isSlowed, gameState && gameState.tree ? gameState.tree.cheats : null ); } const pts = zombie.type === 'buckethead' || zombie.type === 'football' ? 300 : (zombie.type === 'conehead' ? 200 : 100); gameState.addScore(pts); gameState.zombiesKilled++; if (typeof gameState.recordStat === 'function') { gameState.recordStat('zombiesKilled', 1); } // Achievement: zombie_slayer if (gameState.achievements) { gameState.achievements.trigger('zombie_slayer'); } // Tree of Wisdom Cheat: Daisies if (gameState.tree && gameState.tree.cheats && gameState.tree.cheats.daisies) { for (let i = 0; i < 6; i++) { this.particles.push(this.particlePool.get( zombie.x + (Math.random() - 0.5) * 30, zombie.y + 10 + (Math.random() - 0.5) * 16, 0, -8, '#ffffff', 4, 2.5 )); } } // Roguelike: relic_vampire_sap (heal most wounded plant) if (gameState.roguelike && gameState.currentMode === 'roguelike' && gameState.roguelike.hasRelic('relic_vampire_sap')) { let mostDamaged = null; let lowestHpRatio = 1.0; for (const p of gameState.plants) { if (p.toRemove || p.health <= 0) continue; const ratio = p.health / p.maxHealth; if (ratio < lowestHpRatio) { lowestHpRatio = ratio; mostDamaged = p; } } if (mostDamaged) { mostDamaged.health = Math.min(mostDamaged.maxHealth, mostDamaged.health + 40); } } const perkBonus = gameState.workshop ? gameState.workshop.getPerkLevel('luckyDrop') * 0.35 : 0; // Coins Drop if (Math.random() < 0.35 * (1 + perkBonus)) { const r = Math.random(); const coinType = r < 0.15 ? 'diamond' : (r < 0.5 ? 'gold' : 'silver'); gameState.spawnCoin(zombie.x, zombie.y, coinType); } // Materials Drop (Workshop Crafting) if (gameState.workshop) { // 1. Scrap metal from armored enemies if (['buckethead', 'conehead', 'football', 'duckytube'].includes(zombie.type)) { if (Math.random() < 0.45 * (1 + perkBonus)) { gameState.workshop.spawnLoot(zombie.x, zombie.y, 'scrapMetal'); } } else { // 2. Bio-humus from regular/organic enemies if (Math.random() < 0.35 * (1 + perkBonus)) { gameState.workshop.spawnLoot(zombie.x, zombie.y, 'bioHumus'); } } // 3. Rare Crystals from elite/flag enemies if (zombie.type === 'flag' || zombie.type === 'football' || Math.random() < 0.08 * (1 + perkBonus)) { if (Math.random() < 0.12 * (1 + perkBonus)) { gameState.workshop.spawnLoot(zombie.x, zombie.y, 'crystals'); } } } } draw(ctx) { for (const p of this.particles) { ctx.save(); const alpha = Math.max(0, p.life / p.maxLife); ctx.globalAlpha = alpha; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } for (const h of this.heads) { h.draw(ctx); } } };