991 lines
37 KiB
JavaScript
991 lines
37 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════
|
||
MAIN GAME ENGINE
|
||
═══════════════════════════════════════════════════════════════ */
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// CANVAS & CONTEXT
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const canvas = document.getElementById('gameCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// SAVE SYSTEM
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const SaveSystem = {
|
||
saveKey: 'platformer_save',
|
||
scoresKey: 'platformer_scores',
|
||
|
||
saveGame: function(data) {
|
||
try {
|
||
localStorage.setItem(this.saveKey, JSON.stringify(data));
|
||
} catch(e) { console.log('Save failed:', e); }
|
||
},
|
||
|
||
loadGame: function() {
|
||
try {
|
||
const data = localStorage.getItem(this.saveKey);
|
||
return data ? JSON.parse(data) : null;
|
||
} catch(e) { return null; }
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// ACHIEVEMENTS SYSTEM
|
||
// ═══════════════════════════════════════════════════════════════
|
||
achievementsKey: 'platformer_achievements',
|
||
|
||
getAchievements: function() {
|
||
try {
|
||
const data = localStorage.getItem(this.achievementsKey);
|
||
return data ? JSON.parse(data) : {};
|
||
} catch(e) { return {}; }
|
||
},
|
||
|
||
unlockAchievement: function(id) {
|
||
const achievements = this.getAchievements();
|
||
if (!achievements[id]) {
|
||
achievements[id] = true;
|
||
localStorage.setItem(this.achievementsKey, JSON.stringify(achievements));
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
saveScore: function(score) {
|
||
try {
|
||
const scores = this.getScores();
|
||
scores.push({ score: score, date: Date.now() });
|
||
scores.sort((a, b) => b.score - a.score);
|
||
scores = scores.slice(0, 10);
|
||
localStorage.setItem(this.scoresKey, JSON.stringify(scores));
|
||
} catch(e) {}
|
||
},
|
||
|
||
getScores: function() {
|
||
try {
|
||
const data = localStorage.getItem(this.scoresKey);
|
||
return data ? JSON.parse(data) : [];
|
||
} catch(e) { return []; }
|
||
},
|
||
|
||
clearSave: function() {
|
||
localStorage.removeItem(this.saveKey);
|
||
}
|
||
};
|
||
|
||
// Achievements list
|
||
const ACHIEVEMENTS = [
|
||
{ id: 'first_coin', name: 'Первая монетка', icon: '🪙', desc: 'Соберите 1 монету' },
|
||
{ id: 'coins_100', name: 'Коллекционер', icon: '💰', desc: 'Соберите 100 монет' },
|
||
{ id: 'level_5', name: 'Продвинутый', icon: '⭐', desc: 'Пройдите 5 уровней' },
|
||
{ id: 'level_10', name: 'Мастер', icon: '🏆', desc: 'Пройдите все уровни' },
|
||
{ id: 'no_hit', name: 'Неуязвимый', icon: '🛡️', desc: 'Пройдите уровень без урона' },
|
||
{ id: 'speedrun', name: 'Спидраннер', icon: '⚡', desc: 'Пройдите уровень за 30 сек' }
|
||
];
|
||
|
||
// Combo system
|
||
let comboCount = 0;
|
||
let comboTimer = 0;
|
||
const COMBO_MAX_TIME = 120; // 2 seconds at 60fps
|
||
|
||
function addCombo(points) {
|
||
comboTimer = COMBO_MAX_TIME;
|
||
comboCount++;
|
||
return points * Math.min(comboCount, 5); // Max 5x multiplier
|
||
}
|
||
|
||
function updateCombo() {
|
||
if (comboTimer > 0) comboTimer--;
|
||
else comboCount = 0;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
let gameRunning = true;
|
||
let paused = false;
|
||
let frameCount = 0;
|
||
let gameTime = 0;
|
||
let totalScore = 0;
|
||
let currentLevelScore = 0;
|
||
let lives = 3;
|
||
let screenShake = 0;
|
||
let maxUnlockedLevel = 1;
|
||
let hasSave = false;
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// UI ELEMENTS
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const messageDiv = document.getElementById('message');
|
||
const messageTitle = document.getElementById('messageTitle');
|
||
const messageStats = document.getElementById('messageStats');
|
||
const scoreDisplay = document.getElementById('score');
|
||
const timerDisplay = document.getElementById('timer');
|
||
const livesContainer = document.getElementById('lives');
|
||
const levelDisplay = document.getElementById('level');
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// INPUT
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const keys = { left: false, right: false, up: false };
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = true;
|
||
if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = true;
|
||
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||
keys.up = true;
|
||
e.preventDefault();
|
||
}
|
||
if (e.code === 'KeyR') restartGame();
|
||
if (e.code === 'Escape') togglePause();
|
||
});
|
||
|
||
document.addEventListener('keyup', (e) => {
|
||
if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = false;
|
||
if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = false;
|
||
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') keys.up = false;
|
||
});
|
||
|
||
// Continue to next level with Enter or Space
|
||
document.addEventListener('keydown', (e) => {
|
||
// Only continue if game is not running AND message is shown AND it's a win
|
||
if (!gameRunning && messageDiv.style.display !== 'none') {
|
||
if (messageTitle.textContent.includes('ПРОЙДЕН') || messageTitle.textContent.includes('ПОБЕДА')) {
|
||
if (e.code === 'Space' || e.code === 'Enter' || e.code === 'ArrowRight' || e.code === 'ArrowDown' || e.code === 'KeyA' || e.code === 'KeyD') {
|
||
e.preventDefault();
|
||
continueToNextLevel();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// PARTICLES SYSTEM
|
||
// ═══════════════════════════════════════════════════════════════
|
||
let particles = [];
|
||
|
||
function createParticles(x, y, color, count = 10, speed = 8) {
|
||
for (let i = 0; i < count; i++) {
|
||
particles.push({
|
||
x: x,
|
||
y: y,
|
||
vx: (Math.random() - 0.5) * speed,
|
||
vy: (Math.random() - 0.5) * speed - 2,
|
||
size: Math.random() * 6 + 2,
|
||
color: color,
|
||
life: 1,
|
||
decay: Math.random() * 0.025 + 0.015
|
||
});
|
||
}
|
||
}
|
||
|
||
// Trail particles for player movement
|
||
let playerTrail = [];
|
||
function createPlayerTrail() {
|
||
if (Math.abs(Player.velocityX) > 2 || Math.abs(Player.velocityY) > 2) {
|
||
playerTrail.push({
|
||
x: Player.x + Player.width / 2,
|
||
y: Player.y + Player.height / 2,
|
||
size: Math.random() * 8 + 4,
|
||
color: Player.color,
|
||
life: 1,
|
||
decay: 0.05
|
||
});
|
||
}
|
||
}
|
||
|
||
function updatePlayerTrail() {
|
||
for (let i = playerTrail.length - 1; i >= 0; i--) {
|
||
playerTrail[i].life -= playerTrail[i].decay;
|
||
if (playerTrail[i].life <= 0) playerTrail.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
function drawPlayerTrail() {
|
||
for (const p of playerTrail) {
|
||
ctx.globalAlpha = p.life * 0.5;
|
||
ctx.fillStyle = p.color;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// Landing particles
|
||
let wasGrounded = true;
|
||
function checkLanding() {
|
||
if (!wasGrounded && Player.grounded) {
|
||
createParticles(Player.x + Player.width / 2, Player.y + Player.height, '#aaa', 8, 4);
|
||
}
|
||
wasGrounded = Player.grounded;
|
||
}
|
||
|
||
function updateParticles() {
|
||
for (let i = particles.length - 1; i >= 0; i--) {
|
||
const p = particles[i];
|
||
p.x += p.vx;
|
||
p.y += p.vy;
|
||
p.vy += 0.15;
|
||
p.life -= p.decay;
|
||
if (p.life <= 0) particles.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
function drawParticles() {
|
||
for (const p of particles) {
|
||
ctx.globalAlpha = p.life;
|
||
ctx.fillStyle = p.color;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// BACKGROUND - Parallax Stars
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const stars = [];
|
||
for (let i = 0; i < 150; i++) {
|
||
stars.push({
|
||
x: Math.random() * canvas.width,
|
||
y: Math.random() * canvas.height,
|
||
size: Math.random() * 2.5 + 0.5,
|
||
speed: Math.random() * 0.4 + 0.05,
|
||
brightness: Math.random(),
|
||
twinkleSpeed: Math.random() * 0.1 + 0.02
|
||
});
|
||
}
|
||
|
||
function drawBackground() {
|
||
// Animated gradient background
|
||
const time = frameCount * 0.01;
|
||
|
||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||
gradient.addColorStop(0, `hsl(${260 + Math.sin(time) * 10}, 50%, 5%)`);
|
||
gradient.addColorStop(0.3, `hsl(${240 + Math.sin(time) * 5}, 60%, 8%)`);
|
||
gradient.addColorStop(0.6, `hsl(${270 + Math.sin(time) * 8}, 40%, 10%)`);
|
||
gradient.addColorStop(1, `hsl(${250 + Math.sin(time) * 6}, 50%, 12%)`);
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Animated nebula clouds
|
||
for (let i = 0; i < 3; i++) {
|
||
const nebulaX = 200 + i * 250 + Math.sin(time + i) * 50;
|
||
const nebulaY = 300 + i * 100 + Math.cos(time * 0.7 + i) * 30;
|
||
const nebulaSize = 150 + Math.sin(time * 0.5 + i * 2) * 30;
|
||
|
||
const nebulaGradient = ctx.createRadialGradient(nebulaX, nebulaY, 0, nebulaX, nebulaY, nebulaSize);
|
||
nebulaGradient.addColorStop(0, `hsla(${i * 60 + 280}, 70%, 30%, 0.08)`);
|
||
nebulaGradient.addColorStop(0.5, `hsla(${i * 60 + 260}, 60%, 20%, 0.04)`);
|
||
nebulaGradient.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = nebulaGradient;
|
||
ctx.beginPath();
|
||
ctx.arc(nebulaX, nebulaY, nebulaSize, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
// Shooting stars (rare)
|
||
if (Math.random() < 0.002) {
|
||
const shootingStar = {
|
||
x: Math.random() * canvas.width,
|
||
y: Math.random() * canvas.height * 0.5,
|
||
length: 50 + Math.random() * 50,
|
||
speed: 15 + Math.random() * 10
|
||
};
|
||
const gradient2 = ctx.createLinearGradient(
|
||
shootingStar.x, shootingStar.y,
|
||
shootingStar.x + shootingStar.length, shootingStar.y + shootingStar.length * 0.3
|
||
);
|
||
gradient2.addColorStop(0, 'rgba(255, 255, 255, 0.8)');
|
||
gradient2.addColorStop(1, 'transparent');
|
||
ctx.strokeStyle = gradient2;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(shootingStar.x, shootingStar.y);
|
||
ctx.lineTo(shootingStar.x + shootingStar.length, shootingStar.y + shootingStar.length * 0.3);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Stars with parallax and twinkle
|
||
for (const star of stars) {
|
||
const twinkle = Math.sin(frameCount * star.twinkleSpeed + star.brightness * 10) * 0.4 + 0.6;
|
||
const starColor = star.brightness > 0.7 ?
|
||
`rgba(100, 200, 255, ${twinkle * star.brightness})` :
|
||
`rgba(255, 255, 255, ${twinkle * star.brightness})`;
|
||
ctx.fillStyle = starColor;
|
||
ctx.beginPath();
|
||
ctx.arc(star.x, star.y, star.size * twinkle, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Subtle movement (parallax)
|
||
if (frameCount % 60 === 0) {
|
||
star.x -= star.speed;
|
||
if (star.x < 0) star.x = canvas.width;
|
||
}
|
||
}
|
||
|
||
// Weather effects - rain
|
||
if (frameCount % 2 === 0) {
|
||
for (let i = 0; i < 3; i++) {
|
||
const rainX = Math.random() * canvas.width;
|
||
const rainY = Math.random() * canvas.height;
|
||
ctx.strokeStyle = 'rgba(150, 200, 255, 0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(rainX, rainY);
|
||
ctx.lineTo(rainX - 2, rainY + 15);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Vignette effect
|
||
const vignetteGradient = ctx.createRadialGradient(
|
||
canvas.width / 2, canvas.height / 2, canvas.height * 0.3,
|
||
canvas.width / 2, canvas.height / 2, canvas.height * 0.8
|
||
);
|
||
vignetteGradient.addColorStop(0, 'transparent');
|
||
vignetteGradient.addColorStop(1, 'rgba(0, 0, 0, 0.5)');
|
||
ctx.fillStyle = vignetteGradient;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// COLLISION DETECTION
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function checkCollision(rect1, rect2) {
|
||
return rect1.x < rect2.x + rect2.width &&
|
||
rect1.x + rect1.width > rect2.x &&
|
||
rect1.y < rect2.y + rect2.height &&
|
||
rect1.y + rect1.height > rect2.y;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// PLAYER DRAWING - Animated Sprite-like
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function drawPlayer() {
|
||
const p = Player;
|
||
|
||
// Skip drawing during invincibility flicker
|
||
if (p.invincible && Math.floor(frameCount / 4) % 2 === 0) return;
|
||
|
||
// Determine animation frame based on state
|
||
let bobOffset = 0;
|
||
let legSpread = 0;
|
||
let armAngle = 0;
|
||
|
||
if (!p.grounded) {
|
||
// Jumping/falling pose
|
||
bobOffset = p.velocityY > 0 ? 2 : -2;
|
||
armAngle = p.velocityY > 0 ? -0.5 : 0.5;
|
||
} else if (Math.abs(p.velocityX) > 0.5) {
|
||
// Running animation
|
||
legSpread = Math.sin(frameCount * 0.3) * 4;
|
||
bobOffset = Math.sin(frameCount * 0.3) * 2;
|
||
armAngle = Math.sin(frameCount * 0.3) * 0.3;
|
||
} else {
|
||
// Idle breathing
|
||
bobOffset = Math.sin(frameCount * 0.05) * 1;
|
||
}
|
||
|
||
// Shadow with glow
|
||
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||
ctx.beginPath();
|
||
ctx.ellipse(p.x + p.width / 2, p.y + p.height + 3 + bobOffset, p.width / 2, 4, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Dynamic glow color based on powerups
|
||
let glowColor = '#00ffff';
|
||
let bodyColor1 = '#00ffff';
|
||
let bodyColor2 = '#00d9ff';
|
||
if (p.hasSpeedBoost) {
|
||
glowColor = '#ff00ff';
|
||
bodyColor1 = '#ff00ff';
|
||
bodyColor2 = '#aa00ff';
|
||
}
|
||
if (p.invincible) {
|
||
glowColor = '#ffff00';
|
||
bodyColor1 = '#ffff00';
|
||
bodyColor2 = '#ffaa00';
|
||
}
|
||
|
||
// Draw arms (behind body)
|
||
ctx.fillStyle = bodyColor2;
|
||
// Left arm
|
||
ctx.save();
|
||
ctx.translate(p.x + 4, p.y + 15 + bobOffset);
|
||
ctx.rotate(-armAngle);
|
||
ctx.fillRect(-3, 0, 6, 15);
|
||
ctx.restore();
|
||
// Right arm
|
||
ctx.save();
|
||
ctx.translate(p.x + p.width - 4, p.y + 15 + bobOffset);
|
||
ctx.rotate(armAngle);
|
||
ctx.fillRect(-3, 0, 6, 15);
|
||
ctx.restore();
|
||
|
||
// Body with gradient
|
||
const bodyGradient = ctx.createLinearGradient(p.x, p.y + bobOffset, p.x + p.width, p.y + p.height + bobOffset);
|
||
bodyGradient.addColorStop(0, bodyColor1);
|
||
bodyGradient.addColorStop(0.5, bodyColor2);
|
||
bodyGradient.addColorStop(1, '#006688');
|
||
ctx.fillStyle = bodyGradient;
|
||
|
||
// Main body
|
||
const r = 6;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x + r, p.y + bobOffset);
|
||
ctx.lineTo(p.x + p.width - r, p.y + bobOffset);
|
||
ctx.quadraticCurveTo(p.x + p.width, p.y + bobOffset, p.x + p.width, p.y + r + bobOffset);
|
||
ctx.lineTo(p.x + p.width, p.y + p.height - r + bobOffset);
|
||
ctx.quadraticCurveTo(p.x + p.width, p.y + p.height + bobOffset, p.x + p.width - r, p.y + p.height + bobOffset);
|
||
ctx.lineTo(p.x + r, p.y + p.height + bobOffset);
|
||
ctx.quadraticCurveTo(p.x, p.y + p.height + bobOffset, p.x, p.y + p.height - r + bobOffset);
|
||
ctx.lineTo(p.x, p.y + r + bobOffset);
|
||
ctx.quadraticCurveTo(p.x, p.y + bobOffset, p.x + r, p.y + bobOffset);
|
||
ctx.fill();
|
||
|
||
// Enhanced glow effect with pulse
|
||
const pulseIntensity = Math.sin(frameCount * 0.1) * 5 + 15;
|
||
ctx.shadowColor = glowColor;
|
||
ctx.shadowBlur = pulseIntensity;
|
||
ctx.strokeStyle = glowColor;
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// Draw legs
|
||
ctx.fillStyle = bodyColor2;
|
||
// Left leg
|
||
ctx.fillRect(p.x + 6 - legSpread/2, p.y + p.height - 5 + bobOffset, 7, 10);
|
||
// Right leg
|
||
ctx.fillRect(p.x + p.width - 13 + legSpread/2, p.y + p.height - 5 + bobOffset, 7, 10);
|
||
|
||
// Inner highlight
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x + 4, p.y + 4 + bobOffset);
|
||
ctx.lineTo(p.x + p.width - 4, p.y + 4 + bobOffset);
|
||
ctx.stroke();
|
||
|
||
// Eyes with direction
|
||
const eyeY = p.y + 12 + bobOffset;
|
||
const eyeOffset = p.facingRight ? 2 : -2;
|
||
|
||
// Eye whites
|
||
ctx.fillStyle = '#fff';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + 8 + eyeOffset, eyeY, 5, 0, Math.PI * 2);
|
||
ctx.arc(p.x + 20 + eyeOffset, eyeY, 5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Pupils
|
||
ctx.fillStyle = '#000';
|
||
const pupilOffset = p.facingRight ? 2 : 0;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + 9 + pupilOffset + eyeOffset, eyeY + 1, 2.5, 0, Math.PI * 2);
|
||
ctx.arc(p.x + 21 + pupilOffset + eyeOffset, eyeY + 1, 2.5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Eye shine
|
||
ctx.fillStyle = '#fff';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + 7 + pupilOffset + eyeOffset, eyeY - 1, 1.5, 0, Math.PI * 2);
|
||
ctx.arc(p.x + 19 + pupilOffset + eyeOffset, eyeY - 1, 1.5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Double jump indicator
|
||
if (Player.hasDoubleJump && !Player.hasUsedDoubleJump) {
|
||
ctx.fillStyle = 'rgba(0, 255, 136, 0.8)';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + p.width / 2, p.y - 10, 5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// SCREEN SHAKE
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function applyScreenShake() {
|
||
if (screenShake > 0) {
|
||
const shakeX = (Math.random() - 0.5) * screenShake;
|
||
const shakeY = (Math.random() - 0.5) * screenShake;
|
||
ctx.translate(shakeX, shakeY);
|
||
screenShake *= 0.9;
|
||
if (screenShake < 0.5) screenShake = 0;
|
||
}
|
||
}
|
||
|
||
function triggerScreenShake(intensity = 10) {
|
||
screenShake = intensity;
|
||
canvas.classList.add('shake');
|
||
setTimeout(() => canvas.classList.remove('shake'), 400);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// GAME LOGIC
|
||
// ═══════════════════════════════════════════════════════════════
|
||
let levelManager = new LevelManager();
|
||
let lastCheckpoint = null;
|
||
|
||
function initLevel(levelIndex) {
|
||
if (!levelManager.loadLevel(levelIndex)) {
|
||
// Game completed
|
||
gameComplete();
|
||
return;
|
||
}
|
||
|
||
// Reset player position
|
||
const level = LEVELS[levelIndex];
|
||
Player.reset(level.playerStart.x, level.playerStart.y);
|
||
|
||
// Reset checkpoint
|
||
lastCheckpoint = null;
|
||
|
||
// Update level display
|
||
if (levelDisplay) {
|
||
levelDisplay.textContent = `УРОВЕНЬ ${levelIndex + 1}: ${level.name}`;
|
||
}
|
||
|
||
// Reset level score
|
||
currentLevelScore = 0;
|
||
}
|
||
|
||
function update() {
|
||
if (!gameRunning || paused) return;
|
||
|
||
frameCount++;
|
||
if (frameCount % 60 === 0) {
|
||
gameTime++;
|
||
timerDisplay.textContent = gameTime;
|
||
}
|
||
|
||
// Update player
|
||
Player.update();
|
||
|
||
// Update level objects
|
||
levelManager.update();
|
||
|
||
// Check landing
|
||
checkLanding();
|
||
|
||
// Create player trail
|
||
createPlayerTrail();
|
||
updatePlayerTrail();
|
||
|
||
// Update particles
|
||
updateParticles();
|
||
|
||
// Player movement
|
||
if (keys.left) {
|
||
Player.velocityX = -Player.speed;
|
||
Player.facingRight = false;
|
||
} else if (keys.right) {
|
||
Player.velocityX = Player.speed;
|
||
Player.facingRight = true;
|
||
} else {
|
||
Player.velocityX = 0;
|
||
}
|
||
|
||
Player.x += Player.velocityX;
|
||
|
||
// Screen boundaries (horizontal)
|
||
if (Player.x < 30) Player.x = 30;
|
||
if (Player.x + Player.width > 870) Player.x = 870 - Player.width;
|
||
|
||
// Jump
|
||
if (keys.up) {
|
||
const wasGrounded = Player.grounded;
|
||
if (Player.jump()) {
|
||
createParticles(Player.x + Player.width / 2, Player.y + Player.height, '#00ffff', 8);
|
||
if (!wasGrounded && Player.hasDoubleJump && Player.hasUsedDoubleJump) {
|
||
AudioSystem.play('doubleJump');
|
||
} else {
|
||
AudioSystem.play('jump');
|
||
}
|
||
}
|
||
keys.up = false; // Prevent hold-to-fly
|
||
}
|
||
|
||
// Gravity
|
||
Player.velocityY += Player.gravity;
|
||
Player.y += Player.velocityY;
|
||
|
||
// Platform collisions
|
||
Player.grounded = false;
|
||
const allPlatforms = levelManager.getAllPlatforms();
|
||
|
||
for (const platform of allPlatforms) {
|
||
if (checkCollision(Player, platform)) {
|
||
// Landing on top
|
||
if (Player.velocityY > 0 && Player.y + Player.height - Player.velocityY <= platform.y + 10) {
|
||
Player.y = platform.y - Player.height;
|
||
Player.velocityY = 0;
|
||
Player.grounded = true;
|
||
Player.hasUsedDoubleJump = false;
|
||
|
||
// Move with platform
|
||
if (platform.isMoving) {
|
||
Player.x += platform.speed * platform.direction;
|
||
}
|
||
}
|
||
// Hitting from below
|
||
else if (Player.velocityY < 0 && Player.y - Player.velocityY >= platform.y + platform.height - 10) {
|
||
Player.y = platform.y + platform.height;
|
||
Player.velocityY = 0;
|
||
}
|
||
// Side collisions
|
||
else if (Player.velocityX > 0) {
|
||
Player.x = platform.x - Player.width;
|
||
} else if (Player.velocityX < 0) {
|
||
Player.x = platform.x + platform.width;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fall off screen
|
||
if (Player.y > canvas.height + 50) {
|
||
takeDamage();
|
||
}
|
||
|
||
// Coin collection
|
||
for (const coin of levelManager.coins) {
|
||
if (!coin.collected && checkCollision(Player, coin)) {
|
||
coin.collected = true;
|
||
currentLevelScore += 10;
|
||
totalScore += 10;
|
||
scoreDisplay.textContent = totalScore;
|
||
// Enhanced sparkle effect with golden trail
|
||
createParticles(coin.x + coin.width / 2, coin.y + coin.height / 2, '#ffd700', 15, 6);
|
||
createParticles(coin.x + coin.width / 2, coin.y + coin.height / 2, '#fff8dc', 10, 8);
|
||
createParticles(coin.x + coin.width / 2, coin.y + coin.height / 2, '#ffec8b', 8, 10);
|
||
AudioSystem.play('coin');
|
||
}
|
||
}
|
||
|
||
// Spike collision
|
||
for (const spike of levelManager.spikes) {
|
||
if (checkCollision(Player, spike)) {
|
||
takeDamage();
|
||
}
|
||
}
|
||
|
||
// Patrol enemy collision
|
||
for (let i = levelManager.patrolEnemies.length - 1; i >= 0; i--) {
|
||
const enemy = levelManager.patrolEnemies[i];
|
||
if (checkCollision(Player, enemy)) {
|
||
// Check if player is falling onto enemy (stomp kill)
|
||
if (Player.velocityY > 0 && Player.y + Player.height < enemy.y + enemy.height / 2) {
|
||
// Enemy defeated - remove and create explosion
|
||
levelManager.patrolEnemies.splice(i, 1);
|
||
// Death explosion - red/orange particles
|
||
createParticles(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, '#ff6b6b', 20, 8);
|
||
createParticles(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, '#ff4444', 15, 10);
|
||
createParticles(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, '#ffaa00', 12, 12);
|
||
// Small bounce
|
||
Player.velocityY = -8;
|
||
// Bonus score
|
||
currentLevelScore += 25;
|
||
totalScore += 25;
|
||
scoreDisplay.textContent = totalScore;
|
||
screenShake = 8;
|
||
AudioSystem.play('powerUp'); // Reuse power-up sound for victory
|
||
} else {
|
||
takeDamage();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Power-up collection
|
||
for (const powerUp of levelManager.powerUps) {
|
||
if (!powerUp.collected && checkCollision(Player, powerUp)) {
|
||
powerUp.collected = true;
|
||
Player.applyPowerUp(powerUp.type);
|
||
createParticles(powerUp.x + powerUp.width / 2, powerUp.y + powerUp.height / 2, powerUp.color, 20);
|
||
triggerScreenShake(5);
|
||
AudioSystem.play('powerUp');
|
||
}
|
||
}
|
||
|
||
// Checkpoint activation
|
||
for (const checkpoint of levelManager.checkpoints) {
|
||
if (!checkpoint.activated && checkCollision(Player, checkpoint)) {
|
||
checkpoint.activated = true;
|
||
lastCheckpoint = { x: checkpoint.x, y: checkpoint.y };
|
||
createParticles(checkpoint.x + checkpoint.width / 2, checkpoint.y + checkpoint.height / 2, '#00ff88', 25);
|
||
AudioSystem.play('checkpoint');
|
||
}
|
||
}
|
||
|
||
// Flag (goal) collision
|
||
if (levelManager.flag && checkCollision(Player, levelManager.flag)) {
|
||
levelComplete();
|
||
}
|
||
|
||
// Portal collision (next level)
|
||
if (levelManager.portal && checkCollision(Player, levelManager.portal)) {
|
||
levelComplete();
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
ctx.save();
|
||
|
||
// Apply screen shake
|
||
applyScreenShake();
|
||
|
||
// Draw background
|
||
drawBackground();
|
||
|
||
// Draw level objects
|
||
levelManager.draw(ctx, frameCount);
|
||
|
||
// Draw player trail
|
||
drawPlayerTrail();
|
||
|
||
// Draw player
|
||
drawPlayer();
|
||
|
||
// Draw particles
|
||
drawParticles();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
function gameLoop() {
|
||
update();
|
||
draw();
|
||
requestAnimationFrame(gameLoop);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// GAME EVENTS
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function takeDamage() {
|
||
if (!Player.takeDamage()) return;
|
||
|
||
AudioSystem.play('hurt');
|
||
lives--;
|
||
updateLivesDisplay();
|
||
createParticles(Player.x + Player.width / 2, Player.y + Player.height / 2, '#ff4757', 20);
|
||
triggerScreenShake(15);
|
||
|
||
if (lives <= 0) {
|
||
gameOver();
|
||
} else {
|
||
respawnAtCheckpoint();
|
||
}
|
||
}
|
||
|
||
function respawnAtCheckpoint() {
|
||
if (lastCheckpoint) {
|
||
Player.reset(lastCheckpoint.x, lastCheckpoint.y);
|
||
} else {
|
||
const level = LEVELS[levelManager.currentLevel];
|
||
Player.reset(level.playerStart.x, level.playerStart.y);
|
||
}
|
||
Player.velocityX = 0;
|
||
Player.velocityY = 0;
|
||
}
|
||
|
||
function updateLivesDisplay() {
|
||
livesContainer.innerHTML = '';
|
||
for (let i = 0; i < 3; i++) {
|
||
const life = document.createElement('div');
|
||
life.className = 'life-icon' + (i >= lives ? ' lost' : '');
|
||
livesContainer.appendChild(life);
|
||
}
|
||
}
|
||
|
||
let levelCompleteCalled = false;
|
||
|
||
function levelComplete() {
|
||
if (levelCompleteCalled) return;
|
||
levelCompleteCalled = true;
|
||
|
||
AudioSystem.play('levelComplete');
|
||
|
||
// Confetti explosion!
|
||
for (let i = 0; i < 50; i++) {
|
||
const colors = ['#ff4757', '#2ed573', '#1e90ff', '#ffd700', '#ff6b81', '#00ff88'];
|
||
createParticles(
|
||
canvas.width / 2 + (Math.random() - 0.5) * 200,
|
||
canvas.height / 2,
|
||
colors[Math.floor(Math.random() * colors.length)],
|
||
3,
|
||
15
|
||
);
|
||
}
|
||
|
||
// Screen flash effect
|
||
triggerScreenShake(20);
|
||
|
||
gameRunning = false;
|
||
totalScore += currentLevelScore;
|
||
totalScore += Math.max(0, 100 - gameTime) * 5; // Time bonus
|
||
|
||
messageTitle.textContent = '🎉 УРОВЕНЬ ПРОЙДЕН! 🎉';
|
||
messageTitle.style.color = '#00ff88';
|
||
messageStats.innerHTML = `
|
||
Монеты: <span style="color: #ffd700">${currentLevelScore}</span><br>
|
||
Бонус за время: <span style="color: #00ffff">${Math.max(0, 100 - gameTime) * 5}</span><br>
|
||
Всего очков: <span style="color: #00ff88">${totalScore}</span>
|
||
`;
|
||
messageDiv.querySelector('button').textContent = 'Следующий уровень →';
|
||
messageDiv.className = 'win';
|
||
messageDiv.style.display = 'block';
|
||
|
||
// Auto advance after delay - only when user presses a key
|
||
// setTimeout(() => {
|
||
// nextLevel();
|
||
// }, 3000);
|
||
}
|
||
|
||
function continueToNextLevel() {
|
||
console.log('continueToNextLevel called, current level:', levelManager.currentLevel, 'total levels:', LEVELS.length);
|
||
if (levelManager.currentLevel < LEVELS.length - 1) {
|
||
nextLevel();
|
||
} else {
|
||
// Game completed - reset to level 1
|
||
console.log('Game completed, restarting from level 1');
|
||
restartGame();
|
||
}
|
||
}
|
||
|
||
function handleMessageButton() {
|
||
if (messageTitle.textContent.includes('ИГРА ОКОНЧЕНА')) {
|
||
restartGame();
|
||
} else {
|
||
continueToNextLevel();
|
||
}
|
||
}
|
||
|
||
function nextLevel() {
|
||
levelCompleteCalled = false;
|
||
messageDiv.style.display = 'none';
|
||
gameTime = 0;
|
||
frameCount = 0;
|
||
lives = 3;
|
||
updateLivesDisplay();
|
||
Player.hasDoubleJump = false;
|
||
Player.hasSpeedBoost = false;
|
||
initLevel(levelManager.currentLevel + 1);
|
||
gameRunning = true;
|
||
}
|
||
|
||
function gameOver() {
|
||
AudioSystem.play('gameOver');
|
||
gameRunning = false;
|
||
messageDiv.querySelector('button').textContent = 'Играть снова';
|
||
messageTitle.textContent = '💀 ИГРА ОКОНЧЕНА';
|
||
messageTitle.style.color = '#ff4757';
|
||
messageStats.innerHTML = `
|
||
Уровень: <span style="color: #00ffff">${levelManager.currentLevel + 1}</span><br>
|
||
Счет: <span style="color: #ffd700">${totalScore}</span>
|
||
`;
|
||
messageDiv.className = 'lose';
|
||
messageDiv.style.display = 'block';
|
||
}
|
||
|
||
function gameComplete() {
|
||
gameRunning = false;
|
||
messageTitle.textContent = '🏆 ПОБЕДА! 🏆';
|
||
messageTitle.style.color = '#ffd700';
|
||
messageStats.innerHTML = `
|
||
Поздравляем! Вы прошли все уровни!<br>
|
||
Итоговый счет: <span style="color: #ffd700; font-size: 24px">${totalScore}</span>
|
||
`;
|
||
messageDiv.className = 'win';
|
||
messageDiv.style.display = 'block';
|
||
}
|
||
|
||
function restartGame() {
|
||
levelCompleteCalled = false;
|
||
messageDiv.querySelector('button').textContent = 'Играть снова';
|
||
totalScore = 0;
|
||
lives = 3;
|
||
gameTime = 0;
|
||
frameCount = 0;
|
||
particles = [];
|
||
screenShake = 0;
|
||
Player.hasDoubleJump = false;
|
||
Player.hasSpeedBoost = false;
|
||
|
||
scoreDisplay.textContent = '0';
|
||
timerDisplay.textContent = '0';
|
||
updateLivesDisplay();
|
||
|
||
levelManager.reset();
|
||
initLevel(0);
|
||
|
||
messageDiv.style.display = 'none';
|
||
gameRunning = true;
|
||
}
|
||
|
||
function togglePause() {
|
||
if (!gameRunning) return;
|
||
paused = !paused;
|
||
|
||
const pauseMenu = document.getElementById('pause-menu');
|
||
if (pauseMenu) {
|
||
pauseMenu.style.display = paused ? 'block' : 'none';
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// START GAME
|
||
// ═══════════════════════════════════════════════════════════════
|
||
updateLivesDisplay();
|
||
initLevel(0);
|
||
gameLoop();
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// MENU FUNCTIONS
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function startGame() {
|
||
document.getElementById('main-menu').style.display = 'none';
|
||
document.getElementById('loading').style.display = 'none';
|
||
document.getElementById('game-wrapper').style.display = 'block';
|
||
AudioSystem.init();
|
||
if (AudioSystem.muted) {
|
||
AudioSystem.toggleMute();
|
||
}
|
||
gameRunning = true;
|
||
}
|
||
|
||
function showSettings() {
|
||
document.getElementById('settings-modal').classList.add('show');
|
||
}
|
||
|
||
function hideSettings() {
|
||
document.getElementById('settings-modal').classList.remove('show');
|
||
}
|
||
|
||
function showAbout() {
|
||
document.getElementById('about-modal').classList.add('show');
|
||
}
|
||
|
||
function hideAbout() {
|
||
document.getElementById('about-modal').classList.remove('show');
|
||
}
|
||
|
||
function toggleSound() {
|
||
const muted = AudioSystem.toggleMute();
|
||
document.getElementById('sound-toggle').textContent = muted ? 'ВЫКЛ' : 'ВКЛ';
|
||
}
|
||
|
||
function toggleFullscreen() {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen();
|
||
} else {
|
||
document.exitFullscreen();
|
||
}
|
||
}
|
||
|
||
function exitToMenu() {
|
||
document.getElementById('game-wrapper').style.display = 'none';
|
||
document.getElementById('main-menu').style.display = 'flex';
|
||
document.getElementById('pause-menu').style.display = 'none';
|
||
messageDiv.style.display = 'none';
|
||
gameRunning = false;
|
||
}
|