feat(polish & stats): add wallnut degradation states, hit flash, decapitation particle, frost crystals, dave dialogues and lifetime player stats
This commit is contained in:
@@ -2777,3 +2777,127 @@ body.theme-winter .banner-card {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Crazy Dave Story Dialogues & Balloons ─── */
|
||||||
|
.dave-card {
|
||||||
|
max-width: 520px;
|
||||||
|
width: 92%;
|
||||||
|
background: linear-gradient(135deg, #2b1810 0%, #170d08 100%);
|
||||||
|
border: 3px solid #ffb300;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.9), 0 0 25px rgba(255, 179, 0, 0.35);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-character-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 179, 0, 0.25);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-avatar {
|
||||||
|
font-size: 40px;
|
||||||
|
background: radial-gradient(circle, #5d4037, #21120b);
|
||||||
|
border: 2px solid #ffd54f;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
animation: daveWobble 2.2s infinite ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes daveWobble {
|
||||||
|
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||||
|
25% { transform: rotate(-5deg) scale(1.05); }
|
||||||
|
75% { transform: rotate(5deg) scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-title-wrap {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-speaker-name {
|
||||||
|
color: #ffca28;
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-subtitle {
|
||||||
|
color: #bcaaa4;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-speech-bubble {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 2px solid #8d6e63;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
text-align: left;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dave-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff9c4;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dave-continue {
|
||||||
|
background: linear-gradient(135deg, #2e7d32, #43a047);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 10px 32px;
|
||||||
|
border: 2px solid #a5d6a7;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: 0 4px 14px rgba(46, 125, 50, 0.5);
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dave-continue:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Profile Extended Lifetime Statistics ─── */
|
||||||
|
.profile-lifetime-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffca28;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: left;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifetime-stats-grid {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-box.full-width {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Crazy Dave Story Dialogue Modal -->
|
||||||
|
<div id="dave-modal" class="overlay hidden">
|
||||||
|
<div class="banner-card dave-card">
|
||||||
|
<div class="dave-character-wrap">
|
||||||
|
<div class="dave-avatar">🍳🧔♂️</div>
|
||||||
|
<div class="dave-title-wrap">
|
||||||
|
<h2 id="dave-modal-speaker" class="dave-speaker-name">Безумный Дэйв</h2>
|
||||||
|
<div id="dave-modal-subtitle" class="dave-subtitle">Твой чудаковатый сосед</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dave-speech-bubble">
|
||||||
|
<p id="dave-modal-text" class="dave-text">
|
||||||
|
"Эй, сосед! Ночью солнце с неба не падает! Обязательно возьми Солнцегриб!"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="dave-actions">
|
||||||
|
<button type="button" id="btn-dave-continue" class="btn btn-hero btn-dave-continue">ДАЛЕЕ / В БОЙ ⚔️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Workshop & Laboratory Modal -->
|
<!-- Workshop & Laboratory Modal -->
|
||||||
<div id="workshop-modal" class="overlay hidden">
|
<div id="workshop-modal" class="overlay hidden">
|
||||||
<div class="banner-card workshop-card">
|
<div class="banner-card workshop-card">
|
||||||
@@ -445,6 +466,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Extended Lifetime Statistics -->
|
||||||
|
<div class="profile-lifetime-section">
|
||||||
|
<div class="profile-section-title">📊 ОБЩАЯ СТАТИСТИКА ИГРОКА</div>
|
||||||
|
<div class="profile-stats-grid lifetime-stats-grid">
|
||||||
|
<div class="profile-stat-box">
|
||||||
|
<span class="stat-label">🧟 Зомби уничтожено</span>
|
||||||
|
<span id="profile-stat-zombies" class="stat-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat-box">
|
||||||
|
<span class="stat-label">☀️ Солнца выращено</span>
|
||||||
|
<span id="profile-stat-sun" class="stat-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat-box">
|
||||||
|
<span class="stat-label">🌱 Растений посажено</span>
|
||||||
|
<span id="profile-stat-plants" class="stat-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat-box">
|
||||||
|
<span class="stat-label">🏁 Уровней завершено</span>
|
||||||
|
<span id="profile-stat-levels-won" class="stat-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat-box full-width">
|
||||||
|
<span class="stat-label">🪙 Всего монет заработано</span>
|
||||||
|
<span id="profile-stat-coins-earned" class="stat-value">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Backup & Restore Buttons (Profile View) -->
|
<!-- Backup & Restore Buttons (Profile View) -->
|
||||||
<div class="auth-backup-section" style="margin-top: 14px; padding: 10px; background: rgba(0, 0, 0, 0.25); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.15);">
|
<div class="auth-backup-section" style="margin-top: 14px; padding: 10px; background: rgba(0, 0, 0, 0.25); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.15);">
|
||||||
<div style="font-size: 12px; color: #b0bec5; text-align: center; margin-bottom: 8px;">💾 Резервная копия сохранений:</div>
|
<div style="font-size: 12px; color: #b0bec5; text-align: center; margin-bottom: 8px;">💾 Резервная копия сохранений:</div>
|
||||||
|
|||||||
+369
-33
@@ -49,6 +49,11 @@ window.Plant = class Plant {
|
|||||||
this.armorReduction = this.level >= 2 ? 0.15 : 0;
|
this.armorReduction = this.level >= 2 ? 0.15 : 0;
|
||||||
this.recoilDps = this.level === 3 ? 18 : 0; // Thorns on Tier 3!
|
this.recoilDps = this.level === 3 ? 18 : 0; // Thorns on Tier 3!
|
||||||
break;
|
break;
|
||||||
|
case 'tallnut':
|
||||||
|
this.maxHealth = this.level === 3 ? 4800 : (this.level === 2 ? 3600 : 2400);
|
||||||
|
this.armorReduction = this.level >= 2 ? 0.2 : 0.05;
|
||||||
|
this.recoilDps = this.level === 3 ? 25 : 0;
|
||||||
|
break;
|
||||||
case 'cherrybomb':
|
case 'cherrybomb':
|
||||||
this.fuse = 1.2;
|
this.fuse = 1.2;
|
||||||
this.fuseTimer = 0;
|
this.fuseTimer = 0;
|
||||||
@@ -262,6 +267,7 @@ window.Plant = class Plant {
|
|||||||
z.takeDamage(1800);
|
z.takeDamage(1800);
|
||||||
gameState.addScore(250);
|
gameState.addScore(250);
|
||||||
gameState.zombiesKilled++;
|
gameState.zombiesKilled++;
|
||||||
|
if (typeof gameState.recordStat === 'function') gameState.recordStat('zombiesKilled', 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +290,7 @@ window.Plant = class Plant {
|
|||||||
victim.takeDamage(9999);
|
victim.takeDamage(9999);
|
||||||
gameState.addScore(200);
|
gameState.addScore(200);
|
||||||
gameState.zombiesKilled++;
|
gameState.zombiesKilled++;
|
||||||
|
if (typeof gameState.recordStat === 'function') gameState.recordStat('zombiesKilled', 1);
|
||||||
if (window.PvZAudio) window.PvZAudio.playGulp();
|
if (window.PvZAudio) window.PvZAudio.playGulp();
|
||||||
|
|
||||||
if (this.level === 3 && this.bitesLeft > 1) {
|
if (this.level === 3 && this.bitesLeft > 1) {
|
||||||
@@ -324,6 +331,7 @@ window.Plant = class Plant {
|
|||||||
z.takeDamage(1800);
|
z.takeDamage(1800);
|
||||||
gameState.addScore(250);
|
gameState.addScore(250);
|
||||||
gameState.zombiesKilled++;
|
gameState.zombiesKilled++;
|
||||||
|
if (typeof gameState.recordStat === 'function') gameState.recordStat('zombiesKilled', 1);
|
||||||
killedAny = true;
|
killedAny = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,6 +390,9 @@ window.Plant = class Plant {
|
|||||||
case 'wallnut':
|
case 'wallnut':
|
||||||
this.drawWallnut(ctx);
|
this.drawWallnut(ctx);
|
||||||
break;
|
break;
|
||||||
|
case 'tallnut':
|
||||||
|
this.drawTallnut(ctx);
|
||||||
|
break;
|
||||||
case 'cherrybomb':
|
case 'cherrybomb':
|
||||||
this.drawCherryBomb(ctx);
|
this.drawCherryBomb(ctx);
|
||||||
break;
|
break;
|
||||||
@@ -546,60 +557,385 @@ window.Plant = class Plant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawWallnut(ctx) {
|
drawWallnut(ctx) {
|
||||||
const healthRatio = this.health / this.maxHealth;
|
const healthRatio = Math.max(0, Math.min(1, this.health / this.maxHealth));
|
||||||
|
|
||||||
|
// Base nut body (Outer shell)
|
||||||
ctx.fillStyle = '#8d6e63';
|
ctx.fillStyle = '#8d6e63';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.ellipse(0, 2, 22, 28, 0, 0, Math.PI * 2);
|
ctx.ellipse(0, 2, 22, 28, 0, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
|
// Inner walnut tone / lighting
|
||||||
ctx.fillStyle = '#a1887f';
|
ctx.fillStyle = '#a1887f';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.ellipse(-6, -4, 14, 20, -0.2, 0, Math.PI * 2);
|
ctx.ellipse(-6, -4, 14, 20, -0.2, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.fillStyle = '#ffffff';
|
// Subtle nut shell texture creases
|
||||||
|
ctx.strokeStyle = 'rgba(93, 64, 55, 0.4)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.ellipse(-8, -8, 5, 7, 0, 0, Math.PI * 2);
|
ctx.arc(0, 4, 18, 0.6, 2.5);
|
||||||
ctx.ellipse(8, -8, 5, 7, 0, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.fillStyle = '#3e2723';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(-6, -8, 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.arc(10, -8, 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#3e2723';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
if (healthRatio > 0.66) {
|
|
||||||
ctx.arc(0, 6, 6, 0.2, Math.PI - 0.2);
|
|
||||||
} else if (healthRatio > 0.33) {
|
|
||||||
ctx.moveTo(-6, 8);
|
|
||||||
ctx.lineTo(6, 8);
|
|
||||||
} else {
|
|
||||||
ctx.arc(0, 12, 6, Math.PI + 0.2, Math.PI * 2 - 0.2);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
if (healthRatio <= 0.66) {
|
// Degradation State 2 & 3: Chipped Shell / Bite Marks
|
||||||
|
if (healthRatio <= 0.66 && healthRatio > 0.33) {
|
||||||
|
// Degraded: chipped shell notch on upper right
|
||||||
|
ctx.fillStyle = '#5d4037';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(19, -12, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (healthRatio <= 0.33) {
|
||||||
|
// Heavy Damaged: Large bite marks (deep chunks missing)
|
||||||
|
ctx.fillStyle = '#4e342e';
|
||||||
|
// Upper right large chunk
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(17, -14, 9, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#2b1b17';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower left bite chunk
|
||||||
|
ctx.fillStyle = '#4e342e';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-18, 10, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#2b1b17';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Exposed nut pulp inside bite marks
|
||||||
|
ctx.fillStyle = '#d7ccc8';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(16, -14, 4, 0, Math.PI * 2);
|
||||||
|
ctx.arc(-17, 10, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degradation State 2 & 3: Cracks
|
||||||
|
if (healthRatio <= 0.66 && healthRatio > 0.33) {
|
||||||
|
// Moderate cracks
|
||||||
ctx.strokeStyle = '#3e2723';
|
ctx.strokeStyle = '#3e2723';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(12, -18);
|
// Upper crack
|
||||||
ctx.lineTo(6, -6);
|
ctx.moveTo(14, -20);
|
||||||
ctx.lineTo(14, 4);
|
ctx.lineTo(8, -10);
|
||||||
|
ctx.lineTo(13, -2);
|
||||||
|
// Forehead crack
|
||||||
|
ctx.moveTo(-10, -18);
|
||||||
|
ctx.lineTo(-5, -11);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
} else if (healthRatio <= 0.33) {
|
||||||
if (healthRatio <= 0.33) {
|
// Severe deep cracks
|
||||||
ctx.strokeStyle = '#3e2723';
|
ctx.strokeStyle = '#2b1b17';
|
||||||
ctx.lineWidth = 2.5;
|
ctx.lineWidth = 2.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(-14, -14);
|
// Right heavy fissure
|
||||||
ctx.lineTo(-6, -2);
|
ctx.moveTo(16, -22);
|
||||||
ctx.lineTo(-12, 10);
|
ctx.lineTo(7, -10);
|
||||||
|
ctx.lineTo(12, 0);
|
||||||
|
ctx.lineTo(5, 14);
|
||||||
|
// Left heavy fissure
|
||||||
|
ctx.moveTo(-16, -16);
|
||||||
|
ctx.lineTo(-7, -4);
|
||||||
|
ctx.lineTo(-14, 6);
|
||||||
|
ctx.lineTo(-8, 18);
|
||||||
|
// Center branching crack
|
||||||
|
ctx.moveTo(-2, -16);
|
||||||
|
ctx.lineTo(2, -6);
|
||||||
|
ctx.lineTo(-3, 4);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eyes & Expressions
|
||||||
|
if (healthRatio > 0.66) {
|
||||||
|
// 100%-66%: Smiling fresh walnut, happy sparkling eyes
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-8, -8, 5, 7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(8, -8, 5, 7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Cute shiny pupils
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-6, -8, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.arc(10, -8, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Specular glint
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-7, -9.5, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.arc(9, -9.5, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Rosy cheeks
|
||||||
|
ctx.fillStyle = 'rgba(255, 138, 128, 0.4)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-14, 0, 4, 2.5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(14, 0, 4, 2.5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Happy Smiling Mouth
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 2.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 6, 6.5, 0.2, Math.PI - 0.2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
} else if (healthRatio > 0.33) {
|
||||||
|
// 66%-33%: Degraded state - nervous / concerned eyes, sweat drop, straight tense mouth
|
||||||
|
// Stressed eyes
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-8, -7, 5, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(8, -7, 5, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Looking sideways nervously
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-9, -7, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.arc(7, -7, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Worried slanted eyebrows
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 1.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-12, -15);
|
||||||
|
ctx.lineTo(-4, -13);
|
||||||
|
ctx.moveTo(12, -15);
|
||||||
|
ctx.lineTo(4, -13);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Sweat drop on forehead / temple
|
||||||
|
const sweatBob = Math.sin(this.animTime * 6) * 1.5;
|
||||||
|
ctx.fillStyle = '#4fc3f7';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(14, -17 + sweatBob);
|
||||||
|
ctx.quadraticCurveTo(17, -13 + sweatBob, 14, -10 + sweatBob);
|
||||||
|
ctx.quadraticCurveTo(11, -13 + sweatBob, 14, -17 + sweatBob);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#e1f5fe';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(13.5, -12 + sweatBob, 0.9, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Grimace / tense mouth
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-6, 8);
|
||||||
|
ctx.lineTo(6, 8);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// <33%: Heavy damaged state - crying / panicked eyes, large tears, wailing mouth
|
||||||
|
// Wide panicked eye whites
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-8, -6, 6, 7.5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(8, -6, 6, 7.5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Trembling dilated pupils
|
||||||
|
const tremX = Math.sin(this.animTime * 20) * 0.8;
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-8 + tremX, -6, 3, 0, Math.PI * 2);
|
||||||
|
ctx.arc(8 + tremX, -6, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Severe panicked eyebrows
|
||||||
|
ctx.strokeStyle = '#2b1b17';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-13, -13);
|
||||||
|
ctx.lineTo(-4, -17);
|
||||||
|
ctx.moveTo(13, -13);
|
||||||
|
ctx.lineTo(4, -17);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Streaming Tears down cheeks (animated)
|
||||||
|
const tearPhase = (this.animTime * 5) % 1;
|
||||||
|
ctx.fillStyle = '#29b6f6';
|
||||||
|
// Left tear stream & droplet
|
||||||
|
ctx.fillRect(-9, 1, 2.5, 10);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-7.8, 12 + tearPhase * 6, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Right tear stream & droplet
|
||||||
|
ctx.fillRect(7, 1, 2.5, 10);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(8.2, 12 + tearPhase * 6, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Crying tear pools in eye corners
|
||||||
|
ctx.fillStyle = '#81d4fa';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-8, 0, 3, 0, Math.PI);
|
||||||
|
ctx.arc(8, 0, 3, 0, Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Open wailing panicked mouth
|
||||||
|
ctx.fillStyle = '#212121';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 10, 5.5, 7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#2b1b17';
|
||||||
|
ctx.lineWidth = 1.8;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Trembling lower lip
|
||||||
|
ctx.fillStyle = '#ff8a80';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 13, 3, 0, Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTallnut(ctx) {
|
||||||
|
const healthRatio = Math.max(0, Math.min(1, this.health / this.maxHealth));
|
||||||
|
|
||||||
|
// Tallnut body (Taller silhouette)
|
||||||
|
ctx.fillStyle = '#6d4c41';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, -2, 22, 36, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#8d6e63';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-6, -8, 14, 26, -0.15, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Texture ridge
|
||||||
|
ctx.strokeStyle = 'rgba(62, 39, 35, 0.4)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 2, 24, 0.6, 2.5);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Bite marks & chips
|
||||||
|
if (healthRatio <= 0.66 && healthRatio > 0.33) {
|
||||||
|
ctx.fillStyle = '#4e342e';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(19, -18, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else if (healthRatio <= 0.33) {
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(18, -20, 10, 0, Math.PI * 2);
|
||||||
|
ctx.arc(-18, 8, 8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#d7ccc8';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(17, -20, 4, 0, Math.PI * 2);
|
||||||
|
ctx.arc(-17, 8, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cracks
|
||||||
|
if (healthRatio <= 0.66 && healthRatio > 0.33) {
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(14, -28);
|
||||||
|
ctx.lineTo(7, -16);
|
||||||
|
ctx.lineTo(12, -4);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (healthRatio <= 0.33) {
|
||||||
|
ctx.strokeStyle = '#2b1b17';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(15, -30);
|
||||||
|
ctx.lineTo(6, -14);
|
||||||
|
ctx.lineTo(12, 2);
|
||||||
|
ctx.lineTo(4, 20);
|
||||||
|
ctx.moveTo(-15, -20);
|
||||||
|
ctx.lineTo(-6, -6);
|
||||||
|
ctx.lineTo(-14, 10);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face / Expression
|
||||||
|
if (healthRatio > 0.66) {
|
||||||
|
// Stoic / confident tallnut eyes
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-8, -14, 5, 7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(8, -14, 5, 7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-6, -14, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.arc(10, -14, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Confident smirk
|
||||||
|
ctx.strokeStyle = '#3e2723';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 6, 0.2, Math.PI - 0.2);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (healthRatio > 0.33) {
|
||||||
|
// Determined / grimace
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-8, -13, 5, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(8, -13, 5, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-9, -13, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.arc(7, -13, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Slanted heavy eyebrows
|
||||||
|
ctx.strokeStyle = '#2b1b17';
|
||||||
|
ctx.lineWidth = 2.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-13, -22);
|
||||||
|
ctx.lineTo(-4, -19);
|
||||||
|
ctx.moveTo(13, -22);
|
||||||
|
ctx.lineTo(4, -19);
|
||||||
|
ctx.stroke();
|
||||||
|
// Tight mouth
|
||||||
|
ctx.moveTo(-6, 2);
|
||||||
|
ctx.lineTo(6, 2);
|
||||||
|
ctx.stroke();
|
||||||
|
// Sweat drop
|
||||||
|
ctx.fillStyle = '#4fc3f7';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(14, -22, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
// Panicked / crying
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-8, -12, 6, 7.5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(8, -12, 6, 7.5, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#3e2723';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-8, -12, 3, 0, Math.PI * 2);
|
||||||
|
ctx.arc(8, -12, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Crying tears
|
||||||
|
ctx.fillStyle = '#29b6f6';
|
||||||
|
ctx.fillRect(-9, -4, 2.5, 14);
|
||||||
|
ctx.fillRect(7, -4, 2.5, 14);
|
||||||
|
// Panicked open mouth
|
||||||
|
ctx.fillStyle = '#212121';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 4, 6, 8, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawPotatoMine(ctx) {
|
drawPotatoMine(ctx) {
|
||||||
|
|||||||
+110
-12
@@ -16,7 +16,9 @@ window.Zombie = class Zombie {
|
|||||||
this.eatTimer = 0;
|
this.eatTimer = 0;
|
||||||
|
|
||||||
this.slowTimer = 0;
|
this.slowTimer = 0;
|
||||||
|
this.freezeTimer = 0;
|
||||||
this.hitFlashTimer = 0;
|
this.hitFlashTimer = 0;
|
||||||
|
this.headSpawned = false;
|
||||||
this.animTime = Math.random() * 5;
|
this.animTime = Math.random() * 5;
|
||||||
this.toRemove = false;
|
this.toRemove = false;
|
||||||
|
|
||||||
@@ -69,22 +71,52 @@ window.Zombie = class Zombie {
|
|||||||
this.health = this.maxHealth;
|
this.health = this.maxHealth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isSlowed() {
|
||||||
|
return this.slowTimer > 0 || this.freezeTimer > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
set isSlowed(val) {
|
||||||
|
if (val && this.slowTimer <= 0) {
|
||||||
|
this.slowTimer = 4.0;
|
||||||
|
} else if (!val) {
|
||||||
|
this.slowTimer = 0;
|
||||||
|
this.freezeTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update(dt, gameState) {
|
update(dt, gameState) {
|
||||||
this.animTime += dt;
|
this.animTime += dt;
|
||||||
|
|
||||||
if (this.health <= 0) {
|
if (this.health <= 0) {
|
||||||
|
if (!this.headSpawned && gameState && gameState.combat) {
|
||||||
|
this.headSpawned = true;
|
||||||
|
gameState.combat.spawnDecapitatedHead(
|
||||||
|
this.x,
|
||||||
|
this.y - 24,
|
||||||
|
this.type,
|
||||||
|
this.isAngry,
|
||||||
|
this.isSlowed,
|
||||||
|
gameState.tree ? gameState.tree.cheats : null
|
||||||
|
);
|
||||||
|
}
|
||||||
this.toRemove = true;
|
this.toRemove = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hitFlashTimer > 0) {
|
if (this.hitFlashTimer > 0) {
|
||||||
this.hitFlashTimer -= dt;
|
this.hitFlashTimer -= dt;
|
||||||
|
if (this.hitFlashTimer < 0) this.hitFlashTimer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.freezeTimer > 0) {
|
||||||
|
this.freezeTimer -= dt;
|
||||||
|
if (this.freezeTimer < 0) this.freezeTimer = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Freeze / slow effect countdown
|
// Freeze / slow effect countdown
|
||||||
if (this.slowTimer > 0) {
|
if (this.slowTimer > 0 || this.freezeTimer > 0) {
|
||||||
this.slowTimer -= dt;
|
if (this.slowTimer > 0) this.slowTimer -= dt;
|
||||||
this.speed = this.baseSpeed * 0.5;
|
this.speed = this.baseSpeed * (this.freezeTimer > 0 ? 0.35 : 0.5);
|
||||||
} else {
|
} else {
|
||||||
this.speed = this.baseSpeed;
|
this.speed = this.baseSpeed;
|
||||||
}
|
}
|
||||||
@@ -157,7 +189,7 @@ window.Zombie = class Zombie {
|
|||||||
this.hasPaper = false;
|
this.hasPaper = false;
|
||||||
this.isAngry = true;
|
this.isAngry = true;
|
||||||
this.baseSpeed = 54; // Enraged run!
|
this.baseSpeed = 54; // Enraged run!
|
||||||
this.speed = this.slowTimer > 0 ? 27 : 54;
|
this.speed = this.isSlowed ? 27 : 54;
|
||||||
if (window.PvZAudio) window.PvZAudio.playStrike();
|
if (window.PvZAudio) window.PvZAudio.playStrike();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -169,6 +201,51 @@ window.Zombie = class Zombie {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawFrostCrystal(ctx, cx, cy, size, pulse) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(pulse * 0.4);
|
||||||
|
ctx.strokeStyle = 'rgba(224, 247, 250, 0.9)';
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-size, 0);
|
||||||
|
ctx.lineTo(size, 0);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-size * 0.5, -size * 0.35);
|
||||||
|
ctx.lineTo(-size * 0.5, size * 0.35);
|
||||||
|
ctx.moveTo(size * 0.5, -size * 0.35);
|
||||||
|
ctx.lineTo(size * 0.5, size * 0.35);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.rotate(Math.PI / 3);
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFrostCrystals(ctx) {
|
||||||
|
ctx.save();
|
||||||
|
const pulse = Math.sin(this.animTime * 3);
|
||||||
|
const crys = [
|
||||||
|
{ x: -18, y: -26, s: 4.5 },
|
||||||
|
{ x: 16, y: -22, s: 5.0 },
|
||||||
|
{ x: -16, y: 4, s: 4.0 },
|
||||||
|
{ x: 14, y: 0, s: 4.5 },
|
||||||
|
{ x: -8, y: 24, s: 3.8 },
|
||||||
|
{ x: 10, y: 22, s: 4.0 }
|
||||||
|
];
|
||||||
|
for (let i = 0; i < crys.length; i++) {
|
||||||
|
const c = crys[i];
|
||||||
|
const p = pulse + i;
|
||||||
|
this.drawFrostCrystal(ctx, c.x, c.y, c.s, p);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
draw(ctx, gameState = null) {
|
draw(ctx, gameState = null) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(this.x, this.y);
|
ctx.translate(this.x, this.y);
|
||||||
@@ -189,7 +266,7 @@ window.Zombie = class Zombie {
|
|||||||
: (Math.sin(this.animTime * (this.isAngry ? 8 : (isDancing ? 8 : 3))) * 0.25 + (isDancing ? 0.9 : (this.isAngry ? 0.8 : 0.3)));
|
: (Math.sin(this.animTime * (this.isAngry ? 8 : (isDancing ? 8 : 3))) * 0.25 + (isDancing ? 0.9 : (this.isAngry ? 0.8 : 0.3)));
|
||||||
|
|
||||||
const isHit = this.hitFlashTimer > 0;
|
const isHit = this.hitFlashTimer > 0;
|
||||||
const isSlow = this.slowTimer > 0;
|
const isSlow = this.isSlowed;
|
||||||
|
|
||||||
let skinColor = this.isAngry ? '#b71c1c' : '#809973';
|
let skinColor = this.isAngry ? '#b71c1c' : '#809973';
|
||||||
let coatColor = '#5d4037';
|
let coatColor = '#5d4037';
|
||||||
@@ -203,10 +280,14 @@ window.Zombie = class Zombie {
|
|||||||
if (isSlow) {
|
if (isSlow) {
|
||||||
skinColor = '#81d4fa';
|
skinColor = '#81d4fa';
|
||||||
coatColor = '#455a64';
|
coatColor = '#455a64';
|
||||||
|
pantsColor = '#263238';
|
||||||
}
|
}
|
||||||
if (isHit) {
|
if (isHit) {
|
||||||
skinColor = '#ffffff';
|
skinColor = '#ffffff';
|
||||||
coatColor = '#e0e0e0';
|
coatColor = '#ffffff';
|
||||||
|
pantsColor = '#ffffff';
|
||||||
|
ctx.shadowColor = '#ffffff';
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vault jump vertical offset
|
// Vault jump vertical offset
|
||||||
@@ -368,7 +449,7 @@ window.Zombie = class Zombie {
|
|||||||
|
|
||||||
// Headwear
|
// Headwear
|
||||||
if (this.type === 'conehead' && this.health > 100) {
|
if (this.type === 'conehead' && this.health > 100) {
|
||||||
ctx.fillStyle = '#ff6d00';
|
ctx.fillStyle = isHit ? '#ffffff' : '#ff6d00';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(-14, -14);
|
ctx.moveTo(-14, -14);
|
||||||
ctx.lineTo(14, -14);
|
ctx.lineTo(14, -14);
|
||||||
@@ -385,7 +466,7 @@ window.Zombie = class Zombie {
|
|||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
} else if (this.type === 'buckethead' && this.health > 100) {
|
} else if (this.type === 'buckethead' && this.health > 100) {
|
||||||
ctx.fillStyle = '#90a4ae';
|
ctx.fillStyle = isHit ? '#ffffff' : '#90a4ae';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(-16, -14);
|
ctx.moveTo(-16, -14);
|
||||||
ctx.lineTo(16, -14);
|
ctx.lineTo(16, -14);
|
||||||
@@ -393,12 +474,12 @@ window.Zombie = class Zombie {
|
|||||||
ctx.lineTo(-13, -44);
|
ctx.lineTo(-13, -44);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = '#546e7a';
|
ctx.strokeStyle = isHit ? '#ffffff' : '#546e7a';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
} else if (this.type === 'football' && this.health > 100) {
|
} else if (this.type === 'football' && this.health > 100) {
|
||||||
// Football Helmet
|
// Football Helmet
|
||||||
ctx.fillStyle = '#b71c1c';
|
ctx.fillStyle = isHit ? '#ffffff' : '#b71c1c';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(0, -10, 18, Math.PI, 0);
|
ctx.arc(0, -10, 18, Math.PI, 0);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
@@ -414,14 +495,14 @@ window.Zombie = class Zombie {
|
|||||||
ctx.lineTo(8, -4);
|
ctx.lineTo(8, -4);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
} else if (this.type === 'flag') {
|
} else if (this.type === 'flag') {
|
||||||
ctx.strokeStyle = '#8d6e63';
|
ctx.strokeStyle = isHit ? '#ffffff' : '#8d6e63';
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(10, 10);
|
ctx.moveTo(10, 10);
|
||||||
ctx.lineTo(16, -55);
|
ctx.lineTo(16, -55);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = '#d32f2f';
|
ctx.fillStyle = isHit ? '#ffffff' : '#d32f2f';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(16, -55);
|
ctx.moveTo(16, -55);
|
||||||
ctx.lineTo(40, -45);
|
ctx.lineTo(40, -45);
|
||||||
@@ -432,6 +513,23 @@ window.Zombie = class Zombie {
|
|||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
|
// Frost Crystals visual effect around slowed / frozen zombie
|
||||||
|
if (isSlow && !isHit) {
|
||||||
|
this.drawFrostCrystals(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bright white highlight overlay filter when hit
|
||||||
|
if (isHit) {
|
||||||
|
ctx.save();
|
||||||
|
const flashAlpha = Math.min(1, this.hitFlashTimer / 0.08) * 0.75;
|
||||||
|
ctx.globalAlpha = flashAlpha;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, -6, 22, 44, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
// Health bar if damaged
|
// Health bar if damaged
|
||||||
if (this.health < this.maxHealth) {
|
if (this.health < this.maxHealth) {
|
||||||
const pct = Math.max(0, this.health / this.maxHealth);
|
const pct = Math.max(0, this.health / this.maxHealth);
|
||||||
|
|||||||
+135
-20
@@ -80,6 +80,13 @@ window.GameEngine = class GameEngine {
|
|||||||
// Score & Persistence
|
// Score & Persistence
|
||||||
this.score = 0;
|
this.score = 0;
|
||||||
this.zombiesKilled = 0;
|
this.zombiesKilled = 0;
|
||||||
|
this.stats = {
|
||||||
|
zombiesKilled: 0,
|
||||||
|
sunProduced: 0,
|
||||||
|
plantsPlanted: 0,
|
||||||
|
levelsCompleted: 0,
|
||||||
|
coinsEarned: 0
|
||||||
|
};
|
||||||
this.loadUserData();
|
this.loadUserData();
|
||||||
this.updateRecordDisplay();
|
this.updateRecordDisplay();
|
||||||
this.updateProfileUI();
|
this.updateProfileUI();
|
||||||
@@ -110,6 +117,13 @@ window.GameEngine = class GameEngine {
|
|||||||
this.maxUnlockedLevel = uData.maxUnlockedLevel || Math.max(this.currentLevel, 1);
|
this.maxUnlockedLevel = uData.maxUnlockedLevel || Math.max(this.currentLevel, 1);
|
||||||
this.highScore = uData.highScore || 0;
|
this.highScore = uData.highScore || 0;
|
||||||
this.currentTheme = uData.theme || 'classic';
|
this.currentTheme = uData.theme || 'classic';
|
||||||
|
this.stats = uData.stats || {
|
||||||
|
zombiesKilled: 0,
|
||||||
|
sunProduced: 0,
|
||||||
|
plantsPlanted: 0,
|
||||||
|
levelsCompleted: 0,
|
||||||
|
coinsEarned: 0
|
||||||
|
};
|
||||||
this.applyTheme(this.currentTheme);
|
this.applyTheme(this.currentTheme);
|
||||||
if (this.economy) this.economy.reloadProfileData();
|
if (this.economy) this.economy.reloadProfileData();
|
||||||
if (this.workshop) this.workshop.loadFromAuth();
|
if (this.workshop) this.workshop.loadFromAuth();
|
||||||
@@ -122,10 +136,30 @@ window.GameEngine = class GameEngine {
|
|||||||
this.maxUnlockedLevel = parseInt(localStorage.getItem('pvz_max_level') || this.currentLevel.toString(), 10);
|
this.maxUnlockedLevel = parseInt(localStorage.getItem('pvz_max_level') || this.currentLevel.toString(), 10);
|
||||||
this.highScore = parseInt(localStorage.getItem('pvz_highscore') || '0', 10);
|
this.highScore = parseInt(localStorage.getItem('pvz_highscore') || '0', 10);
|
||||||
this.currentTheme = localStorage.getItem('pvz_theme') || 'classic';
|
this.currentTheme = localStorage.getItem('pvz_theme') || 'classic';
|
||||||
|
try {
|
||||||
|
const rawStats = localStorage.getItem('pvz_stats');
|
||||||
|
this.stats = rawStats ? JSON.parse(rawStats) : {
|
||||||
|
zombiesKilled: 0,
|
||||||
|
sunProduced: 0,
|
||||||
|
plantsPlanted: 0,
|
||||||
|
levelsCompleted: 0,
|
||||||
|
coinsEarned: 0
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
this.stats = { zombiesKilled: 0, sunProduced: 0, plantsPlanted: 0, levelsCompleted: 0, coinsEarned: 0 };
|
||||||
|
}
|
||||||
this.applyTheme(this.currentTheme);
|
this.applyTheme(this.currentTheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordStat(key, amount = 1) {
|
||||||
|
if (!this.stats) {
|
||||||
|
this.stats = { zombiesKilled: 0, sunProduced: 0, plantsPlanted: 0, levelsCompleted: 0, coinsEarned: 0 };
|
||||||
|
}
|
||||||
|
this.stats[key] = (this.stats[key] || 0) + amount;
|
||||||
|
this.saveUserData();
|
||||||
|
}
|
||||||
|
|
||||||
saveUserData() {
|
saveUserData() {
|
||||||
if (window.PvZAuth) {
|
if (window.PvZAuth) {
|
||||||
if (this.achievements) this.achievements.saveToAuth();
|
if (this.achievements) this.achievements.saveToAuth();
|
||||||
@@ -137,6 +171,7 @@ window.GameEngine = class GameEngine {
|
|||||||
maxUnlockedLevel: this.maxUnlockedLevel,
|
maxUnlockedLevel: this.maxUnlockedLevel,
|
||||||
highScore: this.highScore,
|
highScore: this.highScore,
|
||||||
theme: this.currentTheme,
|
theme: this.currentTheme,
|
||||||
|
stats: this.stats,
|
||||||
upgrades: this.economy ? this.economy.upgrades : undefined,
|
upgrades: this.economy ? this.economy.upgrades : undefined,
|
||||||
materials: this.workshop ? this.workshop.materials : undefined,
|
materials: this.workshop ? this.workshop.materials : undefined,
|
||||||
plantLevels: this.workshop ? this.workshop.plantLevels : undefined,
|
plantLevels: this.workshop ? this.workshop.plantLevels : undefined,
|
||||||
@@ -154,6 +189,7 @@ window.GameEngine = class GameEngine {
|
|||||||
localStorage.setItem('pvz_max_level', this.maxUnlockedLevel.toString());
|
localStorage.setItem('pvz_max_level', this.maxUnlockedLevel.toString());
|
||||||
localStorage.setItem('pvz_highscore', this.highScore.toString());
|
localStorage.setItem('pvz_highscore', this.highScore.toString());
|
||||||
localStorage.setItem('pvz_theme', this.currentTheme);
|
localStorage.setItem('pvz_theme', this.currentTheme);
|
||||||
|
localStorage.setItem('pvz_stats', JSON.stringify(this.stats));
|
||||||
if (this.economy) {
|
if (this.economy) {
|
||||||
localStorage.setItem('pvz_coins', this.economy.totalCoins.toString());
|
localStorage.setItem('pvz_coins', this.economy.totalCoins.toString());
|
||||||
}
|
}
|
||||||
@@ -269,6 +305,20 @@ window.GameEngine = class GameEngine {
|
|||||||
const totalMats = (mats.bioHumus || 0) + (mats.scrapMetal || 0) + (mats.sunEssence || 0) + (mats.crystals || 0);
|
const totalMats = (mats.bioHumus || 0) + (mats.scrapMetal || 0) + (mats.sunEssence || 0) + (mats.crystals || 0);
|
||||||
statMaterials.textContent = `${totalMats} шт.`;
|
statMaterials.textContent = `${totalMats} шт.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended lifetime player stats
|
||||||
|
const stats = uData.stats || this.stats || {};
|
||||||
|
const statZombies = document.getElementById('profile-stat-zombies');
|
||||||
|
const statSun = document.getElementById('profile-stat-sun');
|
||||||
|
const statPlants = document.getElementById('profile-stat-plants');
|
||||||
|
const statLevelsWon = document.getElementById('profile-stat-levels-won');
|
||||||
|
const statCoinsEarned = document.getElementById('profile-stat-coins-earned');
|
||||||
|
|
||||||
|
if (statZombies) statZombies.textContent = (stats.zombiesKilled || 0).toLocaleString('ru-RU');
|
||||||
|
if (statSun) statSun.textContent = (stats.sunProduced || 0).toLocaleString('ru-RU');
|
||||||
|
if (statPlants) statPlants.textContent = (stats.plantsPlanted || 0).toLocaleString('ru-RU');
|
||||||
|
if (statLevelsWon) statLevelsWon.textContent = (stats.levelsCompleted || 0).toLocaleString('ru-RU');
|
||||||
|
if (statCoinsEarned) statCoinsEarned.textContent = (stats.coinsEarned || 0).toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,6 +488,39 @@ window.GameEngine = class GameEngine {
|
|||||||
this.plants.push(p43);
|
this.plants.push(p43);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showDaveDialogue(level, mode, onContinue) {
|
||||||
|
const dialogue = window.CampaignSystem ? window.CampaignSystem.getDaveDialogue(level, mode) : null;
|
||||||
|
if (!dialogue) {
|
||||||
|
if (typeof onContinue === 'function') onContinue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('dave-modal');
|
||||||
|
const speakerEl = document.getElementById('dave-modal-speaker');
|
||||||
|
const subEl = document.getElementById('dave-modal-subtitle');
|
||||||
|
const textEl = document.getElementById('dave-modal-text');
|
||||||
|
const btnContinue = document.getElementById('btn-dave-continue');
|
||||||
|
|
||||||
|
if (speakerEl) speakerEl.textContent = dialogue.title || 'Безумный Дэйв';
|
||||||
|
if (subEl) subEl.textContent = 'Твой чудаковатый сосед';
|
||||||
|
if (textEl) textEl.textContent = `«${dialogue.text}»`;
|
||||||
|
if (btnContinue && dialogue.btnText) btnContinue.textContent = dialogue.btnText;
|
||||||
|
|
||||||
|
if (modal) modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (modal) modal.classList.add('hidden');
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playButtonClick();
|
||||||
|
if (typeof onContinue === 'function') onContinue();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (btnContinue) {
|
||||||
|
const newBtn = btnContinue.cloneNode(true);
|
||||||
|
btnContinue.parentNode.replaceChild(newBtn, btnContinue);
|
||||||
|
newBtn.addEventListener('click', handleContinue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let startLvl = 1;
|
let startLvl = 1;
|
||||||
if (this.currentMode === 'adventure') {
|
if (this.currentMode === 'adventure') {
|
||||||
@@ -446,17 +529,19 @@ window.GameEngine = class GameEngine {
|
|||||||
startLvl = this.roguelike ? this.roguelike.currentFloor : 1;
|
startLvl = this.roguelike ? this.roguelike.currentFloor : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed-conveyor mini-games launch immediately, standard modes open deck builder
|
this.showDaveDialogue(startLvl, this.currentMode, () => {
|
||||||
if (this.currentMode === 'bowling' || this.currentMode === 'izombie' || this.currentMode === 'boss' || this.currentMode === 'bossrush') {
|
// Fixed-conveyor mini-games launch immediately, standard modes open deck builder
|
||||||
this.startLevel(startLvl, this.currentMode);
|
if (this.currentMode === 'bowling' || this.currentMode === 'izombie' || this.currentMode === 'boss' || this.currentMode === 'bossrush') {
|
||||||
} else {
|
this.startLevel(startLvl, this.currentMode);
|
||||||
this.openSeedSelectionModal(startLvl, this.currentMode);
|
} else {
|
||||||
}
|
this.openSeedSelectionModal(startLvl, this.currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.loopStarted) {
|
if (!this.loopStarted) {
|
||||||
this.loopStarted = true;
|
this.loopStarted = true;
|
||||||
requestAnimationFrame(this.loop.bind(this));
|
requestAnimationFrame(this.loop.bind(this));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
@@ -494,6 +579,9 @@ window.GameEngine = class GameEngine {
|
|||||||
if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_sun_resonance') && !isFalling) {
|
if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_sun_resonance') && !isFalling) {
|
||||||
finalValue += 15;
|
finalValue += 15;
|
||||||
}
|
}
|
||||||
|
if (!isFalling) {
|
||||||
|
this.recordStat('sunProduced', finalValue);
|
||||||
|
}
|
||||||
this.economy.spawnSun(x, y, finalValue, isFalling, targetY);
|
this.economy.spawnSun(x, y, finalValue, isFalling, targetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +612,7 @@ window.GameEngine = class GameEngine {
|
|||||||
if (z.health <= 0) {
|
if (z.health <= 0) {
|
||||||
this.addScore(250);
|
this.addScore(250);
|
||||||
this.zombiesKilled++;
|
this.zombiesKilled++;
|
||||||
|
this.recordStat('zombiesKilled', 1);
|
||||||
if (Math.random() < 0.4) this.spawnCoin(z.x, z.y, 'gold');
|
if (Math.random() < 0.4) this.spawnCoin(z.x, z.y, 'gold');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,6 +656,7 @@ window.GameEngine = class GameEngine {
|
|||||||
if (this.isVictory) return;
|
if (this.isVictory) return;
|
||||||
this.isVictory = true;
|
this.isVictory = true;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.recordStat('levelsCompleted', 1);
|
||||||
this.addScore(1000);
|
this.addScore(1000);
|
||||||
this.economy.addCoins(100); // 100 coin bonus for level completion!
|
this.economy.addCoins(100); // 100 coin bonus for level completion!
|
||||||
|
|
||||||
@@ -707,6 +797,9 @@ window.GameEngine = class GameEngine {
|
|||||||
this.canvas.addEventListener('mousemove', (e) => {
|
this.canvas.addEventListener('mousemove', (e) => {
|
||||||
const pos = this.getEventPos(e);
|
const pos = this.getEventPos(e);
|
||||||
this.hoverCell = this.grid.getCellFromCoords(pos.x, pos.y);
|
this.hoverCell = this.grid.getCellFromCoords(pos.x, pos.y);
|
||||||
|
if (this.economy) {
|
||||||
|
this.economy.handleMouseMove(pos.x, pos.y);
|
||||||
|
}
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
this.checkTouchCollect(pos);
|
this.checkTouchCollect(pos);
|
||||||
}
|
}
|
||||||
@@ -714,6 +807,9 @@ window.GameEngine = class GameEngine {
|
|||||||
|
|
||||||
this.canvas.addEventListener('mouseleave', () => {
|
this.canvas.addEventListener('mouseleave', () => {
|
||||||
this.hoverCell = null;
|
this.hoverCell = null;
|
||||||
|
if (this.economy) {
|
||||||
|
this.economy.handleMouseMove(-1, -1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.canvas.addEventListener('click', (e) => {
|
this.canvas.addEventListener('click', (e) => {
|
||||||
@@ -736,6 +832,9 @@ window.GameEngine = class GameEngine {
|
|||||||
// Mobile touch with Drag & Drop and continuous swipe-collect
|
// Mobile touch with Drag & Drop and continuous swipe-collect
|
||||||
this.canvas.addEventListener('touchstart', (e) => {
|
this.canvas.addEventListener('touchstart', (e) => {
|
||||||
if (!this.isRunning) return;
|
if (!this.isRunning) return;
|
||||||
|
if (this.economy) {
|
||||||
|
this.economy.handleMouseMove(-1, -1);
|
||||||
|
}
|
||||||
const pos = this.getEventPos(e);
|
const pos = this.getEventPos(e);
|
||||||
|
|
||||||
this.checkTouchCollect(pos);
|
this.checkTouchCollect(pos);
|
||||||
@@ -1000,7 +1099,15 @@ window.GameEngine = class GameEngine {
|
|||||||
const btnRestartWin = document.getElementById('btn-restart-win');
|
const btnRestartWin = document.getElementById('btn-restart-win');
|
||||||
if (btnRestartWin) {
|
if (btnRestartWin) {
|
||||||
btnRestartWin.addEventListener('click', () => {
|
btnRestartWin.addEventListener('click', () => {
|
||||||
this.startLevel(this.currentLevel, this.currentMode);
|
const victoryModal = document.getElementById('victory-modal');
|
||||||
|
if (victoryModal) victoryModal.classList.add('hidden');
|
||||||
|
this.showDaveDialogue(this.currentLevel, this.currentMode, () => {
|
||||||
|
if (this.currentMode === 'bowling' || this.currentMode === 'izombie' || this.currentMode === 'boss' || this.currentMode === 'bossrush' || (this.currentMode === 'adventure' && this.currentLevel === 25)) {
|
||||||
|
this.startLevel(this.currentLevel, this.currentMode);
|
||||||
|
} else {
|
||||||
|
this.openSeedSelectionModal(this.currentLevel, this.currentMode);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1810,15 +1917,17 @@ window.GameEngine = class GameEngine {
|
|||||||
if (startModal) startModal.classList.add('hidden');
|
if (startModal) startModal.classList.add('hidden');
|
||||||
|
|
||||||
if (window.PvZAudio) window.PvZAudio.init();
|
if (window.PvZAudio) window.PvZAudio.init();
|
||||||
if (targetLevel === 25) {
|
this.showDaveDialogue(targetLevel, 'adventure', () => {
|
||||||
this.startLevel(targetLevel, 'adventure');
|
if (targetLevel === 25) {
|
||||||
} else {
|
this.startLevel(targetLevel, 'adventure');
|
||||||
this.openSeedSelectionModal(targetLevel, 'adventure');
|
} else {
|
||||||
}
|
this.openSeedSelectionModal(targetLevel, 'adventure');
|
||||||
if (!this.loopStarted) {
|
}
|
||||||
this.loopStarted = true;
|
if (!this.loopStarted) {
|
||||||
requestAnimationFrame(this.loop.bind(this));
|
this.loopStarted = true;
|
||||||
}
|
requestAnimationFrame(this.loop.bind(this));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2817,6 +2926,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const plant = this.createPlant(card.id, col, row, bounds);
|
const plant = this.createPlant(card.id, col, row, bounds);
|
||||||
this.grid.setPlant(col, row, plant);
|
this.grid.setPlant(col, row, plant);
|
||||||
this.plants.push(plant);
|
this.plants.push(plant);
|
||||||
|
this.recordStat('plantsPlanted', 1);
|
||||||
this.economy.selectedCard = null;
|
this.economy.selectedCard = null;
|
||||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||||
}
|
}
|
||||||
@@ -2838,6 +2948,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const plant = this.createPlant('lilypad', col, row, bounds);
|
const plant = this.createPlant('lilypad', col, row, bounds);
|
||||||
this.grid.setPlant(col, row, plant);
|
this.grid.setPlant(col, row, plant);
|
||||||
this.plants.push(plant);
|
this.plants.push(plant);
|
||||||
|
this.recordStat('plantsPlanted', 1);
|
||||||
this.economy.selectedCard = null;
|
this.economy.selectedCard = null;
|
||||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||||
}
|
}
|
||||||
@@ -2851,6 +2962,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const plant = this.createPlant(card.id, col, row, bounds);
|
const plant = this.createPlant(card.id, col, row, bounds);
|
||||||
this.grid.setPlant(col, row, plant);
|
this.grid.setPlant(col, row, plant);
|
||||||
this.plants.push(plant);
|
this.plants.push(plant);
|
||||||
|
this.recordStat('plantsPlanted', 1);
|
||||||
this.economy.selectedCard = null;
|
this.economy.selectedCard = null;
|
||||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||||
}
|
}
|
||||||
@@ -2871,6 +2983,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const plant = this.createPlant('flowerpot', col, row, bounds);
|
const plant = this.createPlant('flowerpot', col, row, bounds);
|
||||||
this.grid.setPlant(col, row, plant);
|
this.grid.setPlant(col, row, plant);
|
||||||
this.plants.push(plant);
|
this.plants.push(plant);
|
||||||
|
this.recordStat('plantsPlanted', 1);
|
||||||
this.economy.selectedCard = null;
|
this.economy.selectedCard = null;
|
||||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||||
}
|
}
|
||||||
@@ -2885,6 +2998,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const plant = this.createPlant(card.id, col, row, bounds);
|
const plant = this.createPlant(card.id, col, row, bounds);
|
||||||
this.grid.setPlant(col, row, plant);
|
this.grid.setPlant(col, row, plant);
|
||||||
this.plants.push(plant);
|
this.plants.push(plant);
|
||||||
|
this.recordStat('plantsPlanted', 1);
|
||||||
this.economy.selectedCard = null;
|
this.economy.selectedCard = null;
|
||||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||||
}
|
}
|
||||||
@@ -2905,6 +3019,7 @@ window.GameEngine = class GameEngine {
|
|||||||
const plant = this.createPlant(card.id, col, row, bounds);
|
const plant = this.createPlant(card.id, col, row, bounds);
|
||||||
this.grid.setPlant(col, row, plant);
|
this.grid.setPlant(col, row, plant);
|
||||||
this.plants.push(plant);
|
this.plants.push(plant);
|
||||||
|
this.recordStat('plantsPlanted', 1);
|
||||||
this.economy.selectedCard = null;
|
this.economy.selectedCard = null;
|
||||||
if (window.PvZAudio) window.PvZAudio.playPlant();
|
if (window.PvZAudio) window.PvZAudio.playPlant();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ window.PvZAuth = (function () {
|
|||||||
maxUnlockedLevel: 1,
|
maxUnlockedLevel: 1,
|
||||||
highScore: 0,
|
highScore: 0,
|
||||||
theme: 'classic',
|
theme: 'classic',
|
||||||
|
stats: {
|
||||||
|
zombiesKilled: 0,
|
||||||
|
sunProduced: 0,
|
||||||
|
plantsPlanted: 0,
|
||||||
|
levelsCompleted: 0,
|
||||||
|
coinsEarned: 0
|
||||||
|
},
|
||||||
upgrades: { extraSlot: false, hasRake: false },
|
upgrades: { extraSlot: false, hasRake: false },
|
||||||
unlockedPlants: ['sunflower', 'peashooter', 'wallnut'],
|
unlockedPlants: ['sunflower', 'peashooter', 'wallnut'],
|
||||||
selectedDeck: ['sunflower', 'peashooter', 'wallnut'],
|
selectedDeck: ['sunflower', 'peashooter', 'wallnut'],
|
||||||
@@ -180,6 +187,10 @@ window.PvZAuth = (function () {
|
|||||||
return {
|
return {
|
||||||
...def,
|
...def,
|
||||||
...serverData,
|
...serverData,
|
||||||
|
stats: {
|
||||||
|
...def.stats,
|
||||||
|
...(serverData.stats || {})
|
||||||
|
},
|
||||||
upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) },
|
upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) },
|
||||||
unlockedPlants: serverData.unlockedPlants || def.unlockedPlants,
|
unlockedPlants: serverData.unlockedPlants || def.unlockedPlants,
|
||||||
selectedDeck: serverData.selectedDeck || def.selectedDeck,
|
selectedDeck: serverData.selectedDeck || def.selectedDeck,
|
||||||
|
|||||||
@@ -8,6 +8,34 @@ window.CampaignSystem = (function() {
|
|||||||
{ id: 5, name: 'Крыша Дома', icon: '🏠', biome: 'roof', range: '5-1 .. 5-5' }
|
{ id: 5, name: 'Крыша Дома', icon: '🏠', biome: 'roof', range: '5-1 .. 5-5' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DAVE_DIALOGUES = {
|
||||||
|
'2-1': {
|
||||||
|
title: '🌙 Безумный Дэйв предупреждает:',
|
||||||
|
text: 'Эй, сосед! Ночью солнце с неба не падает! Обязательно возьми Солнцегриб!',
|
||||||
|
btnText: 'ДАЛЕЕ / В БОЙ ⚔️'
|
||||||
|
},
|
||||||
|
'3-1': {
|
||||||
|
title: '🏊 Безумный Дэйв спешит на помощь:',
|
||||||
|
text: 'Там бассейн! На воде растения тонут — сначала сажай Кувшинки (Lily Pad)!',
|
||||||
|
btnText: 'ДАЛЕЕ / В БОЙ ⚔️'
|
||||||
|
},
|
||||||
|
'4-1': {
|
||||||
|
title: '🌫️ Безумный Дэйв щурится во мгле:',
|
||||||
|
text: 'Ого, какой густой туман! Зомби крадутся в темноте, используй Плантерн и Вздуватель!',
|
||||||
|
btnText: 'ДАЛЕЕ / В БОЙ ⚔️'
|
||||||
|
},
|
||||||
|
'5-1': {
|
||||||
|
title: '🏠 Безумный Дэйв лезет на скат:',
|
||||||
|
text: 'Мы на крыше! Скаты мешают прямой стрельбе — используй Горшки и Катапульты!',
|
||||||
|
btnText: 'ДАЛЕЕ / В БОЙ ⚔️'
|
||||||
|
},
|
||||||
|
'5-5': {
|
||||||
|
title: '🤖 БЕЗУМИЕ ПОЛНОГО МАСШТАБА!',
|
||||||
|
text: 'БЕЗУМИЕ! Доктор Зомбосс выкатил своего гигантского робота! Держи оборону!',
|
||||||
|
btnText: 'ДАЛЕЕ / В БОЙ ⚔️'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
// World 1: Day (1-1 .. 1-5)
|
// World 1: Day (1-1 .. 1-5)
|
||||||
{ level: 1, world: 1, stage: 1, code: '1-1', title: 'Первые шаги', desc: 'Знакомство с садом и простыми зомби', waves: 3, biome: 'day', reward: 100 },
|
{ level: 1, world: 1, stage: 1, code: '1-1', title: 'Первые шаги', desc: 'Знакомство с садом и простыми зомби', waves: 3, biome: 'day', reward: 100 },
|
||||||
@@ -48,6 +76,18 @@ window.CampaignSystem = (function() {
|
|||||||
return {
|
return {
|
||||||
WORLDS,
|
WORLDS,
|
||||||
LEVELS,
|
LEVELS,
|
||||||
|
DAVE_DIALOGUES,
|
||||||
|
getDaveDialogue(levelNum, mode = 'adventure') {
|
||||||
|
if (mode === 'boss' || mode === 'bossrush') {
|
||||||
|
return DAVE_DIALOGUES['5-5'];
|
||||||
|
}
|
||||||
|
const num = parseInt(levelNum, 10) || 1;
|
||||||
|
const levelObj = LEVELS.find(l => l.level === num);
|
||||||
|
if (levelObj && DAVE_DIALOGUES[levelObj.code]) {
|
||||||
|
return DAVE_DIALOGUES[levelObj.code];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
getLevel(levelNum) {
|
getLevel(levelNum) {
|
||||||
const num = parseInt(levelNum, 10) || 1;
|
const num = parseInt(levelNum, 10) || 1;
|
||||||
return LEVELS.find(l => l.level === num) || LEVELS[0];
|
return LEVELS.find(l => l.level === num) || LEVELS[0];
|
||||||
|
|||||||
@@ -47,11 +47,175 @@ window.ParticlePool = class ParticlePool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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)
|
// Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops)
|
||||||
window.CombatSystem = class CombatSystem {
|
window.CombatSystem = class CombatSystem {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.particlePool = new window.ParticlePool(150);
|
this.particlePool = new window.ParticlePool(150);
|
||||||
this.particles = [];
|
this.particles = [];
|
||||||
|
this.heads = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
@@ -59,6 +223,12 @@ window.CombatSystem = class CombatSystem {
|
|||||||
this.particlePool.releaseAll(this.particles);
|
this.particlePool.releaseAll(this.particles);
|
||||||
}
|
}
|
||||||
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) {
|
createSplash(x, y, color = '#8bc34a', count = 7) {
|
||||||
@@ -110,6 +280,17 @@ window.CombatSystem = class CombatSystem {
|
|||||||
}
|
}
|
||||||
this.particles.length = writeIdx;
|
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
|
// 2. Projectiles vs Zombies
|
||||||
for (const proj of gameState.projectiles) {
|
for (const proj of gameState.projectiles) {
|
||||||
if (proj.toRemove) continue;
|
if (proj.toRemove) continue;
|
||||||
@@ -279,9 +460,24 @@ window.CombatSystem = class CombatSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleZombieDeath(zombie, gameState) {
|
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);
|
const pts = zombie.type === 'buckethead' || zombie.type === 'football' ? 300 : (zombie.type === 'conehead' ? 200 : 100);
|
||||||
gameState.addScore(pts);
|
gameState.addScore(pts);
|
||||||
gameState.zombiesKilled++;
|
gameState.zombiesKilled++;
|
||||||
|
if (typeof gameState.recordStat === 'function') {
|
||||||
|
gameState.recordStat('zombiesKilled', 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Achievement: zombie_slayer
|
// Achievement: zombie_slayer
|
||||||
if (gameState.achievements) {
|
if (gameState.achievements) {
|
||||||
@@ -363,5 +559,8 @@ window.CombatSystem = class CombatSystem {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
for (const h of this.heads) {
|
||||||
|
h.draw(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ window.EconomySystem = class EconomySystem {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
this.selectedCard = null;
|
this.selectedCard = null;
|
||||||
|
this.hoveredCard = null;
|
||||||
|
this.hoveredCardIndex = -1;
|
||||||
|
this.hoveredCardPos = null;
|
||||||
this.animTime = 0;
|
this.animTime = 0;
|
||||||
this.setDeckForLevel(1, 'day');
|
this.setDeckForLevel(1, 'day');
|
||||||
}
|
}
|
||||||
@@ -276,6 +279,9 @@ window.EconomySystem = class EconomySystem {
|
|||||||
this.coins = [];
|
this.coins = [];
|
||||||
this.skySunTimer = 0;
|
this.skySunTimer = 0;
|
||||||
this.selectedCard = null;
|
this.selectedCard = null;
|
||||||
|
this.hoveredCard = null;
|
||||||
|
this.hoveredCardIndex = -1;
|
||||||
|
this.hoveredCardPos = null;
|
||||||
this.cards.forEach(c => c.timer = 0);
|
this.cards.forEach(c => c.timer = 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +307,9 @@ window.EconomySystem = class EconomySystem {
|
|||||||
|
|
||||||
addCoins(amount) {
|
addCoins(amount) {
|
||||||
this.totalCoins += amount;
|
this.totalCoins += amount;
|
||||||
|
if (amount > 0 && this.engine && typeof this.engine.recordStat === 'function') {
|
||||||
|
this.engine.recordStat('coinsEarned', amount);
|
||||||
|
}
|
||||||
this.saveProfileData();
|
this.saveProfileData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +502,175 @@ window.EconomySystem = class EconomySystem {
|
|||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMouseMove(mouseX, mouseY) {
|
||||||
|
this.hoveredCard = null;
|
||||||
|
this.hoveredCardIndex = -1;
|
||||||
|
this.hoveredCardPos = null;
|
||||||
|
|
||||||
|
if (mouseX < 0 || mouseY < 0) return;
|
||||||
|
|
||||||
|
const cardStartX = 105;
|
||||||
|
const cardY = 8;
|
||||||
|
const cardW = 66;
|
||||||
|
const cardH = 72;
|
||||||
|
const spacing = 72;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.cards.length; i++) {
|
||||||
|
const card = this.cards[i];
|
||||||
|
const cx = cardStartX + i * spacing;
|
||||||
|
if (mouseX >= cx && mouseX <= cx + cardW && mouseY >= cardY && mouseY <= cardY + cardH) {
|
||||||
|
this.hoveredCard = card;
|
||||||
|
this.hoveredCardIndex = i;
|
||||||
|
this.hoveredCardPos = { x: cx, y: cardY, w: cardW, h: cardH };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shovel hover
|
||||||
|
const shovelX = cardStartX + this.cards.length * spacing + 12;
|
||||||
|
if (mouseX >= shovelX && mouseX <= shovelX + 54 && mouseY >= cardY && mouseY <= cardY + cardH) {
|
||||||
|
this.hoveredCard = { id: 'shovel', name: 'Лопата', cost: 0, cooldown: 0 };
|
||||||
|
this.hoveredCardIndex = -2;
|
||||||
|
this.hoveredCardPos = { x: shovelX, y: cardY, w: 54, h: cardH };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardInfo(cardId) {
|
||||||
|
const p = window.PLANT_CATALOGUE ? window.PLANT_CATALOGUE.find(item => item.id === cardId) : null;
|
||||||
|
if (p) {
|
||||||
|
return {
|
||||||
|
name: p.name,
|
||||||
|
icon: p.icon || '🌱',
|
||||||
|
cost: p.cost,
|
||||||
|
cooldown: p.cooldown,
|
||||||
|
desc: p.desc || 'Защитник газона.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const specialMap = {
|
||||||
|
shovel: { name: 'Лопата', icon: '⛏️', cost: 0, cooldown: 0, desc: 'Выкапывает любое растение с газона, освобождая клетку для новой посадки.' },
|
||||||
|
bowling_nut: { name: 'Орех-шар', icon: '🎳', cost: 0, cooldown: 4.0, desc: 'Катится по дорожке на огромной скорости и сбивает всех зомби на своём пути.' },
|
||||||
|
bowling_cherry: { name: 'Взрывной орех', icon: '💥', cost: 0, cooldown: 12.0, desc: 'Взрывается при первом контакте с зомби в радиусе 3х3 клетки.' },
|
||||||
|
z_normal: { name: 'Обычный зомби', icon: '🧟', cost: 50, cooldown: 2.0, desc: 'Базовый зомби-пехотинец. Идёт вперёд и пожирает растения.' },
|
||||||
|
z_conehead: { name: 'Зомби с конусом', icon: '⚠️', cost: 75, cooldown: 3.0, desc: 'Дорожный конус удваивает запас здоровья зомби.' },
|
||||||
|
z_polevaulter: { name: 'Зомби с шестом', icon: '🏃', cost: 75, cooldown: 4.0, desc: 'Быстро бежит и перепрыгивает первое встреченное растение.' },
|
||||||
|
z_buckethead: { name: 'Зомби с ведром', icon: '🪣', cost: 125, cooldown: 5.0, desc: 'Прочное железное ведро выдерживает массивный урон.' },
|
||||||
|
z_football: { name: 'Зомби-футболист', icon: '🏈', cost: 175, cooldown: 6.0, desc: 'Сверхбыстрый и тяжело бронированный штурмовик.' }
|
||||||
|
};
|
||||||
|
return specialMap[cardId] || {
|
||||||
|
name: cardId,
|
||||||
|
icon: '🌱',
|
||||||
|
cost: 0,
|
||||||
|
cooldown: 5.0,
|
||||||
|
desc: 'Защитник сада.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getRechargeSpeedText(cd) {
|
||||||
|
if (cd <= 0) return 'Мгновенно';
|
||||||
|
if (cd <= 7.5) return `Быстрая (${cd}с)`;
|
||||||
|
if (cd <= 15) return `Средняя (${cd}с)`;
|
||||||
|
if (cd <= 25) return `Медленная (${cd}с)`;
|
||||||
|
return `Очень медленная (${cd}с)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapTooltipText(ctx, text, maxWidth) {
|
||||||
|
if (!text) return [];
|
||||||
|
const words = text.split(' ');
|
||||||
|
const lines = [];
|
||||||
|
let currentLine = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
const width = ctx.measureText(testLine).width;
|
||||||
|
if (width > maxWidth && currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
} else {
|
||||||
|
currentLine = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCardTooltip(ctx, card) {
|
||||||
|
if (!card || !this.hoveredCardPos) return;
|
||||||
|
const info = this.getCardInfo(card.id);
|
||||||
|
const boxW = 240;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
const descLines = this.wrapTooltipText(ctx, info.desc, boxW - 24);
|
||||||
|
const boxH = 58 + descLines.length * 15;
|
||||||
|
|
||||||
|
let boxX = this.hoveredCardPos.x - 10;
|
||||||
|
boxX = Math.max(10, Math.min(boxX, 900 - boxW - 10));
|
||||||
|
const boxY = 88;
|
||||||
|
|
||||||
|
// Stylish dark-glass shadow & container
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
ctx.shadowBlur = 14;
|
||||||
|
ctx.shadowOffsetY = 4;
|
||||||
|
|
||||||
|
const bgGrad = ctx.createLinearGradient(boxX, boxY, boxX, boxY + boxH);
|
||||||
|
bgGrad.addColorStop(0, 'rgba(15, 23, 42, 0.96)');
|
||||||
|
bgGrad.addColorStop(1, 'rgba(30, 41, 59, 0.96)');
|
||||||
|
ctx.fillStyle = bgGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(boxX, boxY, boxW, boxH, 8);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Border glow
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
ctx.strokeStyle = 'rgba(105, 240, 174, 0.75)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Top subtle neon accent line
|
||||||
|
const accentGrad = ctx.createLinearGradient(boxX + 10, boxY, boxX + boxW - 10, boxY);
|
||||||
|
accentGrad.addColorStop(0, '#69f0ae');
|
||||||
|
accentGrad.addColorStop(0.5, '#ffd54f');
|
||||||
|
accentGrad.addColorStop(1, '#81d4fa');
|
||||||
|
ctx.fillStyle = accentGrad;
|
||||||
|
ctx.fillRect(boxX + 8, boxY + 1.5, boxW - 16, 2);
|
||||||
|
|
||||||
|
// Title & Icon
|
||||||
|
ctx.fillStyle = '#ffeb3b';
|
||||||
|
ctx.font = 'bold 13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
let titleStr = `${info.icon} ${info.name}`;
|
||||||
|
if (this.workshop && card.id !== 'shovel') {
|
||||||
|
const lvl = this.workshop.getPlantLevel(card.id);
|
||||||
|
if (lvl > 1) {
|
||||||
|
titleStr += ` (${lvl === 3 ? '★★★' : '★★'})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.fillText(titleStr, boxX + 12, boxY + 20);
|
||||||
|
|
||||||
|
// Subtitle (Cost + Recharge)
|
||||||
|
ctx.fillStyle = '#81d4fa';
|
||||||
|
ctx.font = 'bold 10.5px sans-serif';
|
||||||
|
const costText = this.isConveyor ? 'Бесплатно' : `☀️ ${info.cost}`;
|
||||||
|
const cdText = this.getRechargeSpeedText(info.cooldown);
|
||||||
|
ctx.fillText(`Стоимость: ${costText} ⏱️ ${cdText}`, boxX + 12, boxY + 36);
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(boxX + 10, boxY + 44);
|
||||||
|
ctx.lineTo(boxX + boxW - 10, boxY + 44);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Description lines
|
||||||
|
ctx.fillStyle = '#cfd8dc';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
for (let i = 0; i < descLines.length; i++) {
|
||||||
|
ctx.fillText(descLines[i], boxX + 12, boxY + 58 + i * 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
startCooldown(cardId) {
|
startCooldown(cardId) {
|
||||||
const card = this.cards.find(c => c.id === cardId);
|
const card = this.cards.find(c => c.id === cardId);
|
||||||
if (card) {
|
if (card) {
|
||||||
@@ -666,6 +844,11 @@ window.EconomySystem = class EconomySystem {
|
|||||||
for (const c of this.coins) {
|
for (const c of this.coins) {
|
||||||
this.drawCoinGraphic(ctx, c.x, c.y, c.radius, c.type, c.pulse);
|
this.drawCoinGraphic(ctx, c.x, c.y, c.radius, c.type, c.pulse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Dynamic Hover Tooltip on Deck Card
|
||||||
|
if (this.hoveredCard) {
|
||||||
|
this.drawCardTooltip(ctx, this.hoveredCard);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawSunGraphic(ctx, x, y, radius, pulseAngle, alpha = 1) {
|
drawSunGraphic(ctx, x, y, radius, pulseAngle, alpha = 1) {
|
||||||
|
|||||||
@@ -880,6 +880,46 @@ def test_qol_controls_and_backup():
|
|||||||
for name, cond in qol_checks:
|
for name, cond in qol_checks:
|
||||||
record(name, "PASS" if cond else "FAIL")
|
record(name, "PASS" if cond else "FAIL")
|
||||||
|
|
||||||
|
def test_visual_polish_dave_dialogues_and_stats():
|
||||||
|
print("\n--- 11e. Visual Polish, Crazy Dave Dialogues & Lifetime Stats ---")
|
||||||
|
html_path = os.path.join(GAME_DIR, "index.html")
|
||||||
|
with open(html_path, "r", encoding="utf-8") as f:
|
||||||
|
html_code = f.read()
|
||||||
|
|
||||||
|
plant_js_path = os.path.join(GAME_DIR, "js/entities/plant.js")
|
||||||
|
with open(plant_js_path, "r", encoding="utf-8") as f:
|
||||||
|
plant_code = f.read()
|
||||||
|
|
||||||
|
zombie_js_path = os.path.join(GAME_DIR, "js/entities/zombie.js")
|
||||||
|
with open(zombie_js_path, "r", encoding="utf-8") as f:
|
||||||
|
zombie_code = f.read()
|
||||||
|
|
||||||
|
combat_js_path = os.path.join(GAME_DIR, "js/systems/combat.js")
|
||||||
|
with open(combat_js_path, "r", encoding="utf-8") as f:
|
||||||
|
combat_code = f.read()
|
||||||
|
|
||||||
|
camp_js_path = os.path.join(GAME_DIR, "js/systems/campaign.js")
|
||||||
|
with open(camp_js_path, "r", encoding="utf-8") as f:
|
||||||
|
camp_code = f.read()
|
||||||
|
|
||||||
|
auth_js_path = os.path.join(GAME_DIR, "js/systems/auth.js")
|
||||||
|
with open(auth_js_path, "r", encoding="utf-8") as f:
|
||||||
|
auth_code = f.read()
|
||||||
|
|
||||||
|
juice_checks = [
|
||||||
|
("Wallnut degradation states (cracks, bites, tears) implemented", "drawWallnut" in plant_code and "healthRatio <= 0.33" in plant_code),
|
||||||
|
("Hit flash white effect on zombies implemented", "hitFlashTimer" in zombie_code and "white" in zombie_code),
|
||||||
|
("Zombie flying decapitated head particle implemented", "ZombieHeadParticle" in combat_code),
|
||||||
|
("Frost/freeze crystal particles and tint for slowed zombies", "isSlow" in zombie_code and "drawFrostCrystals" in zombie_code),
|
||||||
|
("Crazy Dave dialogue modal markup present in HTML", 'id="dave-modal"' in html_code and 'id="btn-dave-continue"' in html_code),
|
||||||
|
("Crazy Dave campaign dialogues defined in campaign.js", "DAVE_DIALOGUES" in camp_code and "Солнцегриб" in camp_code),
|
||||||
|
("Lifetime player stats tracking in auth.js", "zombiesKilled" in auth_code and "sunProduced" in auth_code and "plantsPlanted" in auth_code),
|
||||||
|
("Lifetime stats grid present in auth modal", "lifetime-stats-grid" in html_code and "profile-stat-zombies" in html_code),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, cond in juice_checks:
|
||||||
|
record(name, "PASS" if cond else "FAIL")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("==================================================")
|
print("==================================================")
|
||||||
print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱")
|
print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱")
|
||||||
@@ -899,6 +939,7 @@ def main():
|
|||||||
test_plant_shop_and_deck_selection()
|
test_plant_shop_and_deck_selection()
|
||||||
test_modern_dashboard_and_fullscreen()
|
test_modern_dashboard_and_fullscreen()
|
||||||
test_qol_controls_and_backup()
|
test_qol_controls_and_backup()
|
||||||
|
test_visual_polish_dave_dialogues_and_stats()
|
||||||
test_http_endpoint()
|
test_http_endpoint()
|
||||||
|
|
||||||
passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")
|
passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")
|
||||||
|
|||||||
Reference in New Issue
Block a user