567 lines
16 KiB
JavaScript
567 lines
16 KiB
JavaScript
// 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);
|
|
}
|
|
}
|
|
};
|