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:
2026-09-17 09:30:52 +00:00
commit 6457a8904e
50 changed files with 14486 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
// Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops)
window.CombatSystem = class CombatSystem {
constructor() {
this.particles = [];
}
reset() {
this.particles = [];
}
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({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color,
size: 3 + Math.random() * 3,
life: 0.35,
maxLife: 0.35
});
}
}
createExplosionParticles(x, y) {
for (let i = 0; i < 35; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 50 + Math.random() * 200;
const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242'];
this.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color: colors[Math.floor(Math.random() * colors.length)],
size: 5 + Math.random() * 8,
life: 0.65,
maxLife: 0.65
});
}
}
update(dt, gameState) {
// 1. Update Particles
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
}
this.particles = this.particles.filter(p => p.life > 0);
// 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) {
const pts = zombie.type === 'buckethead' || zombie.type === 'football' ? 300 : (zombie.type === 'conehead' ? 200 : 100);
gameState.addScore(pts);
gameState.zombiesKilled++;
// 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({
x: zombie.x + (Math.random() - 0.5) * 30,
y: zombie.y + 10 + (Math.random() - 0.5) * 16,
vx: 0,
vy: -8,
color: '#ffffff',
size: 4,
life: 2.5,
maxLife: 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();
}
}
};