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:
@@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user