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:
Antigravity committed 2026-09-17 09:30:52 +00:00
commit 6457a8904e
50 files changed
+14486

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
// Web Audio API Procedural Sound Synthesizer and Asset Helpers
window.PvZAudio = (function() {
let ctx = null;
let muted = false;
let musicInterval = null;
let musicPlaying = false;
let musicStep = 0;
const N = {
D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, Bb2: 116.54, B2: 123.47,
C3: 130.81, D3: 146.83, Eb3: 155.56, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, Bb3: 233.08, B3: 246.94,
C4: 261.63, Csharp4: 277.18, D4: 293.66, Eb4: 311.13, E4: 329.63, F4: 349.23, Fsharp4: 369.99, G4: 392.00, Gsharp4: 415.30, A4: 440.00, Bb4: 466.16, B4: 493.88,
C5: 523.25, Csharp5: 554.37, D5: 587.33, Eb5: 622.25, E5: 659.25, F5: 698.46, Fsharp5: 739.99, G5: 783.99, A5: 880.00, Bb5: 932.33, B5: 987.77,
C6: 1046.50
};
let currentThemeName = 'day';
const THEMES = {
day: {
tempo: 230,
bassType: 'triangle',
leadType: 'sine',
bassVol: 0.10,
leadVol: 0.08,
bass: [
N.A2, null, N.E3, null, N.A2, null, N.E3, null,
N.D3, null, N.A3, null, N.E3, null, N.G3, null
],
lead: [
N.A4, null, N.C5, N.E5, null, N.D5, N.C5, N.B4,
N.A4, null, N.C5, N.A4, null, N.B4, N.C5, N.B4
]
},
night: {
tempo: 280,
bassType: 'sine',
leadType: 'triangle',
bassVol: 0.12,
leadVol: 0.07,
bass: [
N.C3, null, N.G3, null, N.A2, null, N.E3, null,
N.F3, null, N.C4, null, N.G3, null, N.B3, null
],
lead: [
N.C5, null, N.Eb5, N.G5, null, N.F5, null, N.D5,
N.Eb5, null, N.C5, null, N.G4, null, N.Bb4, null
]
},
pool: {
tempo: 210,
bassType: 'triangle',
leadType: 'sine',
bassVol: 0.11,
leadVol: 0.09,
bass: [
N.G2, null, N.D3, N.G3, N.C3, null, N.G3, N.C4,
N.D3, null, N.A3, N.D4, N.G3, null, N.B3, null
],
lead: [
N.B4, N.D5, null, N.G5, N.Fsharp5, N.D5, null, N.B4,
N.C5, N.E5, null, N.A5, N.G5, N.E5, N.D5, null
]
},
fog: {
tempo: 310,
bassType: 'sawtooth',
leadType: 'triangle',
bassVol: 0.07,
leadVol: 0.07,
bass: [
N.D2, null, N.A2, null, N.D2, null, N.Bb2, null,
N.A2, null, N.F2, null, N.G2, null, N.A2, null
],
lead: [
N.D5, null, null, N.F5, null, N.E5, null, N.Csharp5,
N.D5, null, N.A4, null, null, N.F5, N.E5, null
]
},
roof: {
tempo: 190,
bassType: 'triangle',
leadType: 'sawtooth',
bassVol: 0.11,
leadVol: 0.06,
bass: [
N.F3, N.F3, N.C4, N.F3, N.Bb3, N.Bb3, N.F4, N.Bb3,
N.C3, N.C3, N.G3, N.C4, N.F3, null, N.C4, null
],
lead: [
N.F4, N.A4, N.C5, null, N.D5, N.F5, null, N.C5,
N.Bb4, N.D5, N.F5, null, N.A5, N.G5, N.F5, null
]
},
boss: {
tempo: 165,
bassType: 'sawtooth',
leadType: 'square',
bassVol: 0.09,
leadVol: 0.06,
bass: [
N.E2, N.E2, N.G2, N.E2, N.Bb2, N.A2, N.G2, N.E2,
N.E2, N.E2, N.B2, N.Bb2, N.A2, N.G2, N.E2, N.D3
],
lead: [
N.E5, N.E5, N.G5, null, N.Bb5, N.A5, N.G5, N.E5,
null, N.B5, N.Bb5, N.A5, null, N.G5, N.F5, N.E5
]
}
};
function initCtx() {
if (!ctx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
ctx = new AudioContext();
}
}
if (ctx && ctx.state === 'suspended') {
ctx.resume();
}
}
function playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(startVol, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(endVol, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
} catch (e) {}
}
return {
init: initCtx,
toggleMute: function() {
muted = !muted;
return muted;
},
isMuted: function() {
return muted;
},
playShoot: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(600, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.25, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.12);
} catch (e) {}
},
playHit: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(180, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.08);
} catch (e) {}
},
playSun: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
notes.forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.05);
gain.gain.setValueAtTime(0.18, ctx.currentTime + idx * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + idx * 0.05 + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + idx * 0.05);
osc.stop(ctx.currentTime + idx * 0.05 + 0.2);
});
} catch (e) {}
},
playPlant: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(220, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (e) {}
},
playChomp: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(140, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.1);
} catch (e) {}
},
playExplosion: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
// Noise buffer for explosion
const bufferSize = ctx.sampleRate * 0.5;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.5);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.5, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch (e) {}
},
playMower: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(90, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(240, ctx.currentTime + 1.2);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 1.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 1.2);
} catch (e) {}
},
playCoin: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(987.77, ctx.currentTime); // B5
osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.06); // E6
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
},
playSizzle: function() {
if (muted) return;
initCtx();
if (!ctx) return;
try {
const bufferSize = ctx.sampleRate * 0.8;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.8);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch (e) {}
},
playMine: function() {
this.playExplosion();
playTone(550, 'sine', 0.15, 0.3, 0.01);
},
playVault: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(180, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(620, ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.25, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
},
playGulp: function() {
playTone(180, 'sine', 0.18, 0.35, 0.01);
setTimeout(() => playTone(120, 'sine', 0.22, 0.3, 0.01), 90);
},
playStrike: function() {
playTone(240, 'square', 0.1, 0.3, 0.01);
playTone(120, 'sawtooth', 0.2, 0.35, 0.01);
},
playHugeWave: function() {
playTone(180, 'sawtooth', 0.6, 0.35, 0.01);
},
playWin: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const fanfare = [392, 523, 659, 784, 1046];
fanfare.forEach((f, i) => {
setTimeout(() => playTone(f, 'triangle', 0.35, 0.3, 0.01), i * 160);
});
},
playLose: function() {
if (muted) return;
initCtx();
if (!ctx) return;
const doom = [400, 360, 320, 280, 220];
doom.forEach((f, i) => {
setTimeout(() => playTone(f, 'sawtooth', 0.4, 0.3, 0.01), i * 200);
});
},
// Background Music (Multi-Biome Procedural Chiptune Synthesizer)
startMusic: function(themeName = 'day') {
const targetTheme = THEMES[themeName] ? themeName : 'day';
if (musicPlaying && currentThemeName === targetTheme && musicInterval) {
return;
}
currentThemeName = targetTheme;
musicPlaying = true;
initCtx();
if (musicInterval) {
clearInterval(musicInterval);
musicInterval = null;
}
musicStep = 0;
const theme = THEMES[currentThemeName];
musicInterval = setInterval(() => {
if (!musicPlaying || muted) return;
const b = theme.bass[musicStep % theme.bass.length];
const l = theme.lead[musicStep % theme.lead.length];
if (b) {
playTone(b, theme.bassType, (theme.tempo / 1000) * 1.05, theme.bassVol, 0.001);
}
if (l) {
playTone(l, theme.leadType, (theme.tempo / 1000) * 0.95, theme.leadVol, 0.001);
}
musicStep++;
}, theme.tempo);
},
setTheme: function(themeName) {
if (musicPlaying) {
this.startMusic(themeName);
} else {
currentThemeName = THEMES[themeName] ? themeName : 'day';
}
},
getTheme: function() {
return currentThemeName;
},
stopMusic: function() {
musicPlaying = false;
if (musicInterval) {
clearInterval(musicInterval);
musicInterval = null;
}
},
toggleMusic: function() {
if (musicPlaying) {
this.stopMusic();
return false;
} else {
this.startMusic(currentThemeName);
return true;
}
},
isMusicPlaying: function() {
return musicPlaying;
}
};
})();
+394
View File
@@ -0,0 +1,394 @@
// Autonomous Game Testing API for Plants vs. Zombies
window.PvZTestAPI = (function() {
let engine = null;
return {
setEngine: function(e) {
engine = e;
},
getEngine: function() {
return engine;
},
getState: function() {
if (!engine) return null;
return {
isRunning: engine.isRunning,
isGameOver: engine.isGameOver,
isVictory: engine.isVictory,
currentMode: engine.currentMode,
currentLevel: engine.currentLevel,
sun: engine.economy.sun,
coins: engine.economy.totalCoins,
plantsCount: engine.plants.length,
zombiesCount: engine.zombies.length,
projectilesCount: engine.projectiles.length,
plants: engine.plants.map(p => ({ type: p.type, col: p.col, row: p.row, health: p.health })),
zombies: engine.zombies.map(z => ({ type: z.type, row: z.row, x: Math.round(z.x), health: z.health, isEating: z.isEating }))
};
},
clearField: function() {
if (!engine) return;
engine.plants = [];
engine.zombies = [];
engine.projectiles = [];
for (let r = 0; r < engine.grid.rows; r++) {
for (let c = 0; c < engine.grid.cols; c++) {
engine.grid.grid[r][c] = null;
}
}
},
spawnZombie: function(row, type = 'normal', x = 850) {
if (!engine) return null;
const z = new window.Zombie(row, type, x);
const b = engine.grid.getCellBounds(0, row);
z.y = b.centerY - 5;
engine.zombies.push(z);
return z;
},
plant: function(col, row, type) {
if (!engine) return null;
const b = engine.grid.getCellBounds(col, row);
const p = new window.Plant(type, col, row, b.x, b.y, b.w, b.h);
engine.grid.setPlant(col, row, p);
engine.plants.push(p);
return p;
},
step: function(dt = 0.05, ticks = 1) {
if (!engine) return;
for (let i = 0; i < ticks; i++) {
engine.update(dt);
}
},
runAllTests: function() {
const results = [];
let passed = 0;
function assert(name, condition, errorMsg) {
if (condition) {
results.push({ name, status: 'PASS' });
passed++;
} else {
results.push({ name, status: 'FAIL', error: errorMsg });
}
}
if (!engine) {
return { total: 0, passed: 0, failed: 1, error: 'Engine not initialized' };
}
const origMode = engine.currentMode;
const origLevel = engine.currentLevel;
try {
// Test 1: Peashooter Combat Test
this.clearField();
engine.startLevel(1, 'adventure');
this.clearField();
const p = this.plant(0, 0, 'peashooter');
const z = this.spawnZombie(0, 'normal', 500);
const startHealth = z.health;
// Step simulation until pea hits
this.step(0.1, 40); // 4 seconds of game time
assert('Peashooter damages zombie', z.health < startHealth, `Zombie health did not decrease: ${z.health}`);
// Test 2: Zombie Eating & Resume Movement Bug Test
this.clearField();
const testNut = this.plant(2, 1, 'wallnut');
testNut.health = 40; // low health so it gets eaten fast
const eatingZombie = this.spawnZombie(1, 'normal', testNut.x + 20);
// Step until zombie touches and eats nut
this.step(0.1, 20);
assert('Zombie begins eating plant', eatingZombie.isEating === true || testNut.health <= 0, 'Zombie did not start eating');
// Step until nut is consumed
this.step(0.1, 40);
const nutGone = engine.plants.length === 0 || testNut.health <= 0;
assert('Plant is consumed', nutGone, 'Plant was not consumed');
assert('Zombie resumes walking after plant death (Bug Fix)', eatingZombie.isEating === false, `isEating remained true!`);
// Test 3: Potato Mine Arming and Spudow Explosion
this.clearField();
const mine = this.plant(1, 2, 'potatomine');
assert('Potato mine starts unarmed', mine.isArmed === false, 'Mine started armed');
mine.armTimer = mine.armTime; // fast-forward arming
this.step(0.1, 2);
assert('Potato mine is armed', mine.isArmed === true, 'Mine did not arm');
const boomZombie = this.spawnZombie(2, 'normal', mine.x + 20);
this.step(0.1, 10);
assert('Potato mine explodes on contact', mine.toRemove === true || boomZombie.health <= 0, 'Mine did not explode');
// Test 4: Lawnmower Defense
this.clearField();
engine.grid.initLawnmowers();
const lawnZombie = this.spawnZombie(0, 'normal', 110); // triggers mower at x<=115
this.step(0.1, 10);
assert('Lawnmower activates on breach', engine.grid.lawnmowers[0].active === true || engine.grid.lawnmowers[0].used === true, 'Mower did not activate');
assert('Lawnmower kills intruder', lawnZombie.health <= 0 || lawnZombie.toRemove === true, 'Mower did not kill zombie');
// Test 5: I, Zombie Mode (No Instant Victory Bug Test)
this.clearField();
engine.startLevel(1, 'izombie');
assert('I, Zombie does NOT instantly trigger victory', engine.isVictory === false, 'Instant victory bug detected!');
assert('I, Zombie spawns 5 trophy sunflowers', engine.plants.filter(pl => pl.type === 'sunflower').length === 5, 'Incorrect sunflowers count');
assert('I, Zombie uses zombie deck', engine.economy.cards.some(c => c.id.startsWith('z_')), 'Zombie deck not equipped');
// Step simulation 10 ticks: game should remain running without instant win
this.step(0.1, 10);
assert('I, Zombie stays active during play', engine.isVictory === false && engine.isRunning === true, 'Unexpected victory or stop');
// Win simulation: destroy all sunflowers
for (const pl of engine.plants) {
if (pl.type === 'sunflower') pl.toRemove = true;
}
this.step(0.1, 2);
assert('I, Zombie triggers victory when all sunflowers are destroyed', engine.isVictory === true, 'Victory did not trigger upon sunflower defeat');
// Test 6: Auth & Profiles
window.PvZAuth.loginAsGuest();
assert('Guest mode detected', window.PvZAuth.isGuest() === true, 'Failed to login as guest');
const userRes = window.PvZAuth.login('autotester', 'testpass123');
assert('User registration & login succeeds', userRes.success === true && window.PvZAuth.getCurrentUser() === 'autotester', 'Login failed');
window.PvZAuth.saveUserData({ coins: 999, level: 3, highScore: 500, upgrades: {} });
const loaded = window.PvZAuth.getUserData();
assert('User profile data persists', loaded.coins === 999 && loaded.level === 3, 'Data did not persist');
window.PvZAuth.loginAsGuest(); // restore guest
// Test 7: Main Menu Navigation
engine.openMainMenu();
assert('Main menu pauses game', engine.isRunning === false, 'Game was not paused on menu open');
const startMod = document.getElementById('start-modal');
assert('Main menu opens start modal', startMod && !startMod.classList.contains('hidden'), 'Start modal was not shown');
// Test 8: Workshop, Materials & Plant Upgrades
if (engine.workshop) {
const ws = engine.workshop;
ws.materials.bioHumus = 50;
ws.materials.scrapMetal = 50;
ws.materials.crystals = 5;
engine.economy.totalCoins = 2000;
const pLevelBefore = ws.getPlantLevel('peashooter');
const upgradedT2 = ws.upgradePlant('peashooter');
assert('Workshop upgrades plant to Tier 2', upgradedT2 === true && ws.getPlantLevel('peashooter') === 2, 'Failed to upgrade to Tier 2');
const upgradedT3 = ws.upgradePlant('peashooter');
assert('Workshop upgrades plant to Tier 3', upgradedT3 === true && ws.getPlantLevel('peashooter') === 3, 'Failed to upgrade to Tier 3');
// Check perk upgrade
const perkUpgraded = ws.upgradePerk('hardyRoots');
assert('Workshop upgrades passive perk', perkUpgraded === true && ws.getPerkLevel('hardyRoots') === 1, 'Failed to upgrade perk');
// Check plant creation with upgraded tier and hardy roots
const cellB = engine.grid.getCellBounds(1, 0);
const t3Plant = engine.createPlant('peashooter', 1, 0, cellB);
assert('Planted plant inherits Tier 3 level', t3Plant.level === 3, `Expected level 3, got ${t3Plant.level}`);
assert('Hardy Roots perk boosts plant max HP', t3Plant.maxHealth > 300, `Expected boosted HP, got ${t3Plant.maxHealth}`);
// Restore peashooter level and perk for clean state
ws.plantLevels['peashooter'] = 1;
ws.perks['hardyRoots'] = 0;
} else {
assert('WorkshopSystem exists on engine', false, 'engine.workshop is undefined');
}
// Test 9: Zen Garden and Suburban Almanac
if (engine.zengarden) {
const zg = engine.zengarden;
zg.chocolateCount = 2;
zg.fertilizerCount = 3;
const feedRes = zg.feedSnail();
assert('Feeding Stinky snail with chocolate wakes and boosts snail', feedRes === true && zg.snail.isAwake === true && zg.snail.boostTimer > 0, 'Snail feed failed');
// Plant sprout in empty pot
const emptyIdx = zg.pots.findIndex(p => !p.plantType);
if (emptyIdx !== -1) {
const planted = zg.plantSprout('chomper');
assert('Zen Garden plants new sprout in empty pot', planted === true && zg.pots[emptyIdx].plantType === 'chomper', 'Sprout planting failed');
}
// Water thirsty plant
const thirstyPot = zg.pots.find(p => p.plantType && p.thirstTimer > 0);
if (thirstyPot) {
const watered = zg.waterPlant(thirstyPot.id);
assert('Watering thirsty plant succeeds', watered === true && thirstyPot.thirstTimer === 0, 'Watering failed');
} else {
zg.pots[0].thirstTimer = 10;
const watered = zg.waterPlant(zg.pots[0].id);
assert('Watering plant clears thirst timer', watered === true && zg.pots[0].thirstTimer === 0, 'Watering failed');
}
} else {
assert('ZenGardenSystem exists on engine', false, 'engine.zengarden is undefined');
}
if (engine.almanac) {
const alm = engine.almanac;
assert('Almanac has 13 plants defined', alm.plants.length >= 13, `Expected >= 13 plants, got ${alm.plants.length}`);
assert('Almanac has 8 zombies defined', alm.zombies.length >= 8, `Expected >= 8 zombies, got ${alm.zombies.length}`);
const yeti = alm.getItem('z_yeti');
assert('Almanac contains secret Zombie Yeti', yeti && yeti.id === 'z_yeti', 'Yeti not found in almanac');
} else {
assert('AlmanacSystem exists on engine', false, 'engine.almanac is undefined');
}
// Test 10: Achievements System
if (engine.achievements) {
const achs = engine.achievements;
assert('Achievements list contains 14+ achievements', achs.achievements.length >= 14, `Expected >= 14, got ${achs.achievements.length}`);
achs.trigger('first_plant');
const ach = achs.achievements.find(a => a.id === 'first_plant');
assert('first_plant achievement triggers and completes', ach && ach.completed === true, 'Achievement not completed');
const coinsBefore = engine.economy.totalCoins;
if (ach && !ach.claimed) {
const claimed = achs.claimReward('first_plant');
assert('Claiming achievement grants coins reward', claimed === true && engine.economy.totalCoins > coinsBefore, 'Reward claiming failed');
} else {
assert('Achievement reward already claimed or validated', true);
}
} else {
assert('AchievementSystem exists on engine', false, 'engine.achievements is undefined');
}
// Test 11: Garden Roguelike System
if (engine.roguelike) {
const rgl = engine.roguelike;
rgl.reset();
assert('Roguelike starts at floor 1 with 0 relics', rgl.currentFloor === 1 && rgl.activeRelics.length === 0, 'Reset failed');
const choices = rgl.generateDraftChoices();
assert('generateDraftChoices generates exactly 3 choices', choices && choices.length === 3, `Expected 3 choices, got ${choices ? choices.length : 0}`);
const selected = rgl.selectChoice(0);
assert('selectChoice successfully picks choice and increments floor', selected === true && rgl.currentFloor === 2, 'Select choice failed');
} else {
assert('RoguelikeSystem exists on engine', false, 'engine.roguelike is undefined');
}
// Test 12: Tree of Wisdom & Secret Cheats
if (engine.tree) {
const tree = engine.tree;
assert('TreeSystem has wisdom milestones defined', tree.wisdomList && tree.wisdomList.length >= 8, 'Wisdom list too small');
const initialHeight = tree.height;
engine.economy.totalCoins += 300;
const fed = tree.feedFertilizer();
assert('feedFertilizer increases height by 1', fed === true && tree.height === initialHeight + 1, 'Tree feeding failed');
const cheatState = tree.toggleCheat('mustache');
assert('toggleCheat toggles cheat state', typeof cheatState === 'boolean', 'Cheat toggle failed');
} else {
assert('TreeSystem exists on engine', false, 'engine.tree is undefined');
}
// Test 13: Dr. Zomboss Boss Fight
if (engine.boss) {
const boss = engine.boss;
boss.reset();
assert('Boss starts with 6000 HP and isActive true', boss.health === 6000 && boss.isActive === true, 'Boss init failed');
boss.takeDamage(500);
assert('Boss takeDamage accurately decreases HP', boss.health === 5500, `Expected 5500, got ${boss.health}`);
engine.economy.setBossDeck();
const hasPots = engine.economy.cards.some(c => c.id === 'flowerpot');
const hasMelon = engine.economy.cards.some(c => c.id === 'melonpult');
assert('Boss conveyor deck contains flowerpots and melon-pults', hasPots && hasMelon, 'Boss deck missing items');
} else {
assert('BossSystem exists on engine', false, 'engine.boss is undefined');
}
// Test 14: Leaderboard System
if (engine.leaderboards) {
const lb = engine.leaderboards;
assert('Leaderboards has 6 modes', Object.keys(lb.records).length >= 6, 'Missing leaderboard modes');
lb.submitScore('survival', 99, '99 Флагов (Тест)');
const entries = lb.getEntries('survival');
const top = entries[0];
assert('Leaderboard records and sorts top score', top && top.score === 99, 'Score submission failed');
} else {
assert('LeaderboardSystem exists on engine', false, 'engine.leaderboards is undefined');
}
// Test 15: Roof & Fog Biomes and Lobbed Projectiles
engine.grid.initBiome('roof');
assert('Roof biome initialized', engine.grid.biome === 'roof' && engine.grid.isRoof() === true, 'Roof biome init failed');
engine.grid.initBiome('fog');
assert('Fog biome initialized', engine.grid.biome === 'fog' && engine.grid.isFog() === true, 'Fog biome init failed');
const lobbedProj = new window.Projectile(100, 200, 1, 'melon', 80, 500, 200);
assert('Lobbed projectile initializes with arc parameters', lobbedProj.isLobbed === true && lobbedProj.targetX === 500, 'Lobbed proj init failed');
lobbedProj.update(0.1);
assert('Lobbed projectile progresses along trajectory', lobbedProj.lobProgress > 0, 'Trajectory update failed');
// Test 16: v2.1 Soundtrack, Themes & Boss Rush Mode
if (window.PvZAudio) {
window.PvZAudio.setTheme('boss');
assert('PvZAudio supports multi-biome themes', window.PvZAudio.getTheme() === 'boss', 'Theme switch failed');
window.PvZAudio.setTheme('day');
}
engine.applyTheme('cyberpunk');
assert('Visual themes switch to cyberpunk', engine.currentTheme === 'cyberpunk' && document.body.classList.contains('theme-cyberpunk'), 'Cyberpunk theme failed');
engine.applyTheme('winter');
assert('Visual themes switch to winter', engine.currentTheme === 'winter' && document.body.classList.contains('theme-winter'), 'Winter theme failed');
engine.applyTheme('classic');
assert('Visual themes reset to classic', engine.currentTheme === 'classic', 'Classic theme reset failed');
engine.wave.setLevel(1, 'bossrush');
assert('Boss Rush mode initializes 3 waves', engine.wave.totalWaves === 3 && engine.wave.biome === 'roof', 'Boss Rush init failed');
if (engine.leaderboards) {
assert('Leaderboard includes bossrush mode records', Array.isArray(engine.leaderboards.getEntries('bossrush')), 'Missing bossrush leaderboard');
}
// Test 17: Campaign System & 25 Levels Multi-Biome Progression
if (window.CampaignSystem) {
const worlds = window.CampaignSystem.WORLDS;
const levels = window.CampaignSystem.LEVELS;
assert('CampaignSystem defines 5 worlds', Array.isArray(worlds) && worlds.length === 5, `Expected 5 worlds, got ${worlds.length}`);
assert('CampaignSystem defines 25 levels', Array.isArray(levels) && levels.length === 25, `Expected 25 levels, got ${levels.length}`);
const lvl1 = window.CampaignSystem.getLevel(1);
const lvl11 = window.CampaignSystem.getLevel(11);
const lvl16 = window.CampaignSystem.getLevel(16);
const lvl25 = window.CampaignSystem.getLevel(25);
assert('Level 1-1 is Day Lawn biome', lvl1.code === '1-1' && lvl1.biome === 'day', 'Level 1 biome mismatch');
assert('Level 3-1 is Pool biome', lvl11.code === '3-1' && lvl11.biome === 'pool', 'Level 11 biome mismatch');
assert('Level 4-1 is Fog biome', lvl16.code === '4-1' && lvl16.biome === 'fog', 'Level 16 biome mismatch');
assert('Level 5-5 is Dr. Zomboss Boss fight', lvl25.code === '5-5' && lvl25.isBoss === true && lvl25.biome === 'roof', 'Level 25 boss mismatch');
engine.wave.setLevel(11, 'adventure');
assert('WaveSystem sets pool biome on level 11', engine.wave.biome === 'pool', 'Wave biome mismatch for lvl 11');
engine.wave.setLevel(16, 'adventure');
assert('WaveSystem sets fog biome on level 16', engine.wave.biome === 'fog', 'Wave biome mismatch for lvl 16');
engine.wave.setLevel(25, 'adventure');
assert('WaveSystem configures boss mode on level 25', engine.wave.mode === 'boss' && engine.wave.biome === 'roof', 'Wave boss mismatch for lvl 25');
const fmt = window.CampaignSystem.formatLevel(11);
assert('formatLevel formats code and world icon', fmt.fullLabel.includes('3-1') && fmt.worldIcon === '🏊', 'Format level mismatch');
} else {
assert('CampaignSystem is globally defined', false, 'CampaignSystem not found');
}
} catch (err) {
results.push({ name: 'Exception during test run', status: 'FAIL', error: err.toString() });
} finally {
// Restore original state
engine.startLevel(origLevel, origMode);
}
return {
total: results.length,
passed,
failed: results.length - passed,
results
};
}
};
})();