2562 lines
90 KiB
HTML
2562 lines
90 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GECOFIST v1.5</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #000; overflow: hidden; height: 100vh; font-family: 'Segoe UI', Tahoma, sans-serif; }
|
|
canvas { display: block; cursor: none; }
|
|
|
|
#titleScreen {
|
|
position: fixed; inset: 0; z-index: 100;
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
background: linear-gradient(180deg, #0a0a1a 0%, #1a0505 100%);
|
|
}
|
|
#titleScreen h1 {
|
|
font-size: 6rem; font-weight: 900; letter-spacing: 8px;
|
|
background: linear-gradient(180deg, #ff4444, #ff0000, #880000);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
text-shadow: none; filter: drop-shadow(0 0 30px rgba(255,0,0,0.6));
|
|
margin-bottom: 5px;
|
|
}
|
|
#titleScreen .subtitle {
|
|
color: #ff6b35; font-size: 1.3rem; margin-bottom: 40px; letter-spacing: 3px;
|
|
}
|
|
#titleScreen .instructions {
|
|
color: #888; font-size: 1rem; margin-bottom: 30px; text-align: center; line-height: 1.8;
|
|
}
|
|
#titleScreen button {
|
|
padding: 18px 60px; font-size: 1.5rem; font-weight: 800;
|
|
background: linear-gradient(135deg, #ff4444, #cc0000);
|
|
color: #fff; border: 3px solid #ff6666; border-radius: 15px;
|
|
cursor: pointer; transition: .3s; text-transform: uppercase; letter-spacing: 4px;
|
|
}
|
|
#titleScreen button:hover { transform: scale(1.08); box-shadow: 0 0 40px rgba(255,0,0,0.6); }
|
|
.gecko-preview { font-size: 1rem; color: #ff6b35; margin-bottom: 20px; }
|
|
|
|
#hud {
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
|
|
display: none; justify-content: space-between; align-items: center;
|
|
padding: 10px 25px;
|
|
background: rgba(0,0,0,0.7); border-bottom: 2px solid #cc0000;
|
|
}
|
|
#hud .score { color: #ff4444; font-size: 2rem; font-weight: 900; }
|
|
#hud .combo { color: #ffd700; font-size: 1.2rem; font-weight: 700; }
|
|
#hud .lives { color: #ff6b35; font-size: 1.5rem; }
|
|
#hud .distance { color: #888; font-size: 1rem; }
|
|
|
|
#gameOver {
|
|
position: fixed; inset: 0; z-index: 200;
|
|
display: none; flex-direction: column; align-items: center; justify-content: center;
|
|
background: rgba(20,0,0,0.9); backdrop-filter: blur(10px);
|
|
}
|
|
#gameOver.show { display: flex; }
|
|
#gameOver h2 { font-size: 3.5rem; color: #ff0000; font-weight: 900; margin-bottom: 10px; }
|
|
#gameOver .final { font-size: 4rem; color: #ffd700; font-weight: 900; }
|
|
#gameOver .stats { color: #aaa; font-size: 1.1rem; margin: 20px 0; }
|
|
#gameOver button {
|
|
padding: 15px 50px; font-size: 1.3rem; font-weight: 700;
|
|
background: linear-gradient(135deg, #ff4444, #cc0000);
|
|
color: #fff; border: 2px solid #ff6666; border-radius: 12px;
|
|
cursor: pointer; text-transform: uppercase; letter-spacing: 3px;
|
|
}
|
|
#gameOver button:hover { transform: scale(1.05); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="titleScreen">
|
|
<div class="gecko-preview">🐦</div>
|
|
<h1>GECOFIST</h1>
|
|
<p class="subtitle">LA MASCOTTE EN FURIE</p>
|
|
<p style="color:#555; font-size:0.8rem; margin-bottom: 15px; letter-spacing: 2px;">v1.5</p>
|
|
<div class="instructions">
|
|
<strong style="color:#ff6b35;">SOURIS</strong> pour diriger le Gecko<br>
|
|
<strong style="color:#ff4444;">CLIC</strong> pour lancer des BOULES DE FEU<br>
|
|
<strong style="color:#ffaa00;">ESPACE</strong> = SUPER ATTAQUE (tous les 250 pts)<br>
|
|
<strong style="color:#00ccff;">POWER-UP x2</strong> = tire la boule sur l'icône pour 2 boules de feu !<br>
|
|
Créme les petits vieux. Pas de pitié.
|
|
</div>
|
|
<button onclick="startGame()">FRAPPER</button>
|
|
</div>
|
|
|
|
<div id="hud">
|
|
<div class="score">SCORE: <span id="score">0</span></div>
|
|
<div class="combo" id="combo"></div>
|
|
<div class="super" id="superHUD" style="color:#ffaa00;font-size:1.3rem;font-weight:700;"></div>
|
|
<div class="distance" id="distance">0m</div>
|
|
<div class="lives" id="lives"></div>
|
|
</div>
|
|
|
|
<canvas id="c"></canvas>
|
|
|
|
<div id="gameOver">
|
|
<h2>K.O. TOTAL</h2>
|
|
<div class="final" id="finalScore">0</div>
|
|
<div class="stats" id="goStats"></div>
|
|
<button onclick="restart()">ENCORE</button>
|
|
</div>
|
|
|
|
<script>
|
|
const canvas = document.getElementById('c');
|
|
const ctx = canvas.getContext('2d');
|
|
let W, H;
|
|
let vignetteCanvas = null;
|
|
function createVignette() {
|
|
vignetteCanvas = document.createElement('canvas');
|
|
vignetteCanvas.width = W; vignetteCanvas.height = H;
|
|
const vctx = vignetteCanvas.getContext('2d');
|
|
const vigGrad = vctx.createRadialGradient(W/2, H/2, H*0.3, W/2, H/2, H*0.9);
|
|
vigGrad.addColorStop(0, 'rgba(0,0,0,0)');
|
|
vigGrad.addColorStop(1, 'rgba(0,0,0,0.5)');
|
|
vctx.fillStyle = vigGrad;
|
|
vctx.fillRect(0, 0, W, H);
|
|
}
|
|
function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight; createVignette(); }
|
|
addEventListener('resize', resize); resize();
|
|
|
|
// ========== STATE ==========
|
|
let gameRunning = false;
|
|
let score = 0, kills = 0, combo = 0, maxCombo = 0, comboTimer = 0;
|
|
let lives = 5;
|
|
let superAttackCharges = 0;
|
|
let lastSuperThreshold = 0; // last score milestone that gave a charge
|
|
let superAttackAnim = 0; // animation timer
|
|
let distance = 0;
|
|
let speed = 1;
|
|
let shakeX = 0, shakeY = 0, shakeTime = 0;
|
|
let flashAlpha = 0;
|
|
let mouseX = W/2, mouseY = H/2;
|
|
let punching = false, punchTimer = 0;
|
|
|
|
// Ground
|
|
let groundLines = [];
|
|
for (let i = 0; i < 30; i++) groundLines.push({ z: i * 40 });
|
|
|
|
// Enemies
|
|
let enemies = [];
|
|
let spawnTimer = 0;
|
|
let spawnRate = 80;
|
|
|
|
// Blood
|
|
let bloods = [];
|
|
let bloodPools = [];
|
|
|
|
// Particles (misc)
|
|
let particles = [];
|
|
|
|
// Fireballs
|
|
let fireballs = [];
|
|
let doubleFireball = false;
|
|
let doubleFireballTimer = 0;
|
|
const DOUBLE_FB_DURATION = 600; // ~10 seconds at 60fps
|
|
|
|
// Weapon power-ups
|
|
let powerups = [];
|
|
let powerupSpawnTimer = 0;
|
|
|
|
// Boss
|
|
const bossImg = new Image();
|
|
bossImg.src = 'img/boss.png';
|
|
let bossLoaded = false;
|
|
bossImg.onload = () => { bossLoaded = true; };
|
|
|
|
let bossActive = false;
|
|
let bossHP = 10;
|
|
let bossMaxHP = 10;
|
|
let bossX = 0; // sway position
|
|
let bossHitFlash = 0;
|
|
let bossLasers = []; // active laser beams
|
|
let bossLaserTimer = 0;
|
|
let bossYellTimer = 0; // "T'ES VIRE" text
|
|
let bossYells = [];
|
|
let bossDefeated = false;
|
|
let bossExplodeTimer = 0;
|
|
let nextBossDistance = Infinity;
|
|
const BOSS_INTERVAL = 150;
|
|
|
|
// Mini-Boss
|
|
const minibossImg = new Image();
|
|
minibossImg.src = 'img/miniboss.png';
|
|
let minibossLoaded = false;
|
|
minibossImg.onload = () => { minibossLoaded = true; };
|
|
|
|
let minibossActive = false;
|
|
let minibossHP = 5;
|
|
let minibossMaxHP = 5;
|
|
let minibossX = 0;
|
|
let minibossHitFlash = 0;
|
|
let minibossLasers = [];
|
|
let minibossLaserTimer = 0;
|
|
let minibossYells = [];
|
|
let minibossDefeated = false;
|
|
let minibossExplodeTimer = 0;
|
|
let nextMinibossDistance = 150;
|
|
const MINIBOSS_INTERVAL = 150;
|
|
|
|
// Mini-Boss 2
|
|
const miniboss2Img = new Image();
|
|
miniboss2Img.src = 'img/miniboss2.png';
|
|
let miniboss2Loaded = false;
|
|
miniboss2Img.onload = () => { miniboss2Loaded = true; };
|
|
|
|
let miniboss2Active = false;
|
|
let miniboss2HP = 7;
|
|
let miniboss2MaxHP = 7;
|
|
let miniboss2X = 0;
|
|
let miniboss2HitFlash = 0;
|
|
let miniboss2Lasers = [];
|
|
let miniboss2LaserTimer = 0;
|
|
let miniboss2Yells = [];
|
|
let miniboss2Defeated = false;
|
|
let miniboss2ExplodeTimer = 0;
|
|
let nextMiniboss2Distance = Infinity;
|
|
const MINIBOSS2_INTERVAL = 100;
|
|
|
|
// Mega fight (all 3 bosses at 500m)
|
|
let megaFightTriggered = false;
|
|
let nextMegaFightDistance = 500;
|
|
const MEGAFIGHT_INTERVAL = 500;
|
|
|
|
// ========== GECKO SPRITE (image PNG) ==========
|
|
const geckoImg = new Image();
|
|
geckoImg.src = 'img/geco.png';
|
|
let geckoLoaded = false;
|
|
geckoImg.onload = () => { geckoLoaded = true; };
|
|
|
|
function drawGecko(x, y, scale, isPunching) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
|
|
const s = scale * (isPunching ? 1.15 : 1);
|
|
const w = 160 * s;
|
|
const h = 180 * s;
|
|
|
|
// Slight tilt when punching
|
|
if (isPunching) ctx.rotate(-0.15);
|
|
|
|
// Bob animation
|
|
const bob = Math.sin(Date.now() / 300) * 3;
|
|
|
|
// The fist is the anchor point (at x,y = mouse cursor).
|
|
// The body is drawn to the RIGHT of the fist.
|
|
// On the original image (facing right), the left fist is at ~15% from left, ~40% from top.
|
|
// Flipped: fist is on the left side => we offset the image so fist aligns with (0,0).
|
|
const fistOffX = w * 0.95; // body further to the right of the fist
|
|
const fistOffY = h * 0.12; // fist higher up
|
|
|
|
if (geckoLoaded) {
|
|
ctx.scale(-1, 1);
|
|
ctx.drawImage(geckoImg, -fistOffX, -h * 0.4 + fistOffY + bob, w, h);
|
|
ctx.scale(-1, 1);
|
|
} else {
|
|
ctx.fillStyle = '#e8531e';
|
|
ctx.fillRect(0, -h * 0.4 + bob, w, h);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '16px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('GECO', w / 2, bob);
|
|
}
|
|
|
|
// Impact FX at the fist (which is at 0,0 = mouse cursor)
|
|
if (isPunching) {
|
|
const t = Date.now();
|
|
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 3;
|
|
for (let i = 0; i < 8; i++) {
|
|
const a = (i / 8) * Math.PI * 2 + t / 50;
|
|
const r1 = 14;
|
|
const r2 = 25 + Math.random() * 10;
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.cos(a) * r1, bob + Math.sin(a) * r1);
|
|
ctx.lineTo(Math.cos(a) * r2, bob + Math.sin(a) * r2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(0, bob, 30, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ========== OLD PERSON DRAW (detailed, bigger) ==========
|
|
// Type 0: Grandpa with cane + glasses + beret
|
|
// Type 1: Grandma with handbag + curly hair + pearl necklace
|
|
// Type 2: Old dude with walker, bald comb-over
|
|
function drawOldPerson(x, y, scale, type, hitAnim) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
const s = scale * 1.5;
|
|
ctx.scale(s, s);
|
|
|
|
if (hitAnim > 0) {
|
|
ctx.rotate(hitAnim * 0.8);
|
|
ctx.globalAlpha = Math.max(0, 1 - hitAnim * 0.7);
|
|
}
|
|
|
|
const skin = type === 0 ? '#f0c8a0' : type === 1 ? '#f5d5b8' : '#d9b896';
|
|
const skinShade = type === 0 ? '#d4a87a' : type === 1 ? '#e0b898' : '#c09a72';
|
|
const t = Date.now();
|
|
|
|
// ---- CANE (type 0) / WALKER (type 2) ----
|
|
if (type === 0) {
|
|
ctx.strokeStyle = '#6B3410'; ctx.lineWidth = 3.5; ctx.lineCap = 'round';
|
|
ctx.beginPath(); ctx.moveTo(20, -5); ctx.lineTo(24, 55); ctx.stroke();
|
|
ctx.beginPath(); ctx.arc(20, -7, 5, Math.PI, Math.PI*2); ctx.stroke();
|
|
ctx.fillStyle = '#333';
|
|
ctx.beginPath(); ctx.arc(24, 56, 3, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
if (type === 2) {
|
|
ctx.strokeStyle = '#888'; ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-20, 0); ctx.lineTo(-22, 55);
|
|
ctx.moveTo(20, 0); ctx.lineTo(22, 55);
|
|
ctx.moveTo(-22, 55); ctx.lineTo(22, 55);
|
|
ctx.moveTo(-22, 20); ctx.lineTo(22, 20);
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#c8e550';
|
|
ctx.beginPath(); ctx.arc(-22, 57, 3, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(22, 57, 3, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
|
|
// ---- LEGS ----
|
|
ctx.fillStyle = type === 1 ? '#5a4a6a' : '#4a4a55';
|
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-8, 30); ctx.lineTo(-10, 52); ctx.lineTo(-3, 52); ctx.lineTo(-3, 30);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(3, 30); ctx.lineTo(3, 52); ctx.lineTo(10, 52); ctx.lineTo(8, 30);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
// ---- SHOES ----
|
|
ctx.fillStyle = type === 1 ? '#8B4560' : '#4a3528';
|
|
ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-12, 51); ctx.lineTo(-12, 56); ctx.lineTo(0, 56); ctx.lineTo(0, 53); ctx.lineTo(-3, 51);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(2, 51); ctx.lineTo(2, 56); ctx.lineTo(14, 56); ctx.lineTo(14, 53); ctx.lineTo(10, 51);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
// ---- BODY ----
|
|
const bodyColor = type === 0 ? '#5555aa' : type === 1 ? '#b04070' : '#7a8855';
|
|
ctx.fillStyle = bodyColor; ctx.strokeStyle = '#333'; ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-16, -2); ctx.quadraticCurveTo(-18, 15, -14, 32);
|
|
ctx.lineTo(14, 32); ctx.quadraticCurveTo(18, 15, 16, -2);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
// Body details
|
|
if (type === 0) {
|
|
ctx.strokeStyle = '#444477'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, 30); ctx.stroke();
|
|
ctx.fillStyle = '#ddd';
|
|
for (let i = 0; i < 4; i++) { ctx.beginPath(); ctx.arc(0, 4+i*7, 2, 0, Math.PI*2); ctx.fill(); }
|
|
ctx.fillStyle = '#eee'; ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-8, -2); ctx.lineTo(0, 4); ctx.lineTo(8, -2);
|
|
ctx.lineTo(5, -4); ctx.lineTo(0, 0); ctx.lineTo(-5, -4);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
}
|
|
if (type === 1) {
|
|
ctx.fillStyle = 'rgba(255,200,220,0.5)';
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.beginPath(); ctx.arc(-8+(i%3)*8, 5+Math.floor(i/3)*12, 3, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
ctx.fillStyle = '#f0e8d8'; ctx.strokeStyle = '#ccc'; ctx.lineWidth = 0.5;
|
|
for (let i = 0; i < 7; i++) {
|
|
const a = Math.PI*0.15 + i*Math.PI*0.1;
|
|
ctx.beginPath(); ctx.arc(Math.cos(a)*12, -2+Math.sin(a)*6, 2, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
}
|
|
}
|
|
if (type === 2) {
|
|
ctx.strokeStyle = '#aa4444'; ctx.lineWidth = 2.5;
|
|
ctx.beginPath(); ctx.moveTo(-6, -2); ctx.lineTo(-8, 30); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(6, -2); ctx.lineTo(8, 30); ctx.stroke();
|
|
ctx.strokeStyle = 'rgba(100,80,60,0.3)'; ctx.lineWidth = 1;
|
|
for (let i = 0; i < 5; i++) { ctx.beginPath(); ctx.moveTo(-15, 2+i*6); ctx.lineTo(15, 2+i*6); ctx.stroke(); }
|
|
}
|
|
|
|
// ---- ARMS ----
|
|
ctx.fillStyle = skin; ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-16, 2); ctx.quadraticCurveTo(-24, 15, -20, 28);
|
|
ctx.quadraticCurveTo(-18, 30, -14, 28); ctx.quadraticCurveTo(-16, 15, -14, 2);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(16, 2); ctx.quadraticCurveTo(24, 15, 20, 28);
|
|
ctx.quadraticCurveTo(18, 30, 14, 28); ctx.quadraticCurveTo(16, 15, 14, 2);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
// Hands
|
|
ctx.beginPath(); ctx.arc(-20, 29, 4, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath(); ctx.arc(20, 29, 4, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
ctx.strokeStyle = skinShade; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(-22, 28); ctx.lineTo(-24, 31); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(22, 28); ctx.lineTo(24, 31); ctx.stroke();
|
|
|
|
// Handbag (grandma)
|
|
if (type === 1) {
|
|
ctx.fillStyle = '#8B2252'; ctx.strokeStyle = '#5a1535'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.roundRect(18, 15, 14, 12, 3); ctx.fill(); ctx.stroke();
|
|
ctx.strokeStyle = '#5a1535'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(25, 12, 6, Math.PI, Math.PI*2); ctx.stroke();
|
|
ctx.fillStyle = '#daa520';
|
|
ctx.beginPath(); ctx.arc(25, 17, 2, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
|
|
// ---- NECK ----
|
|
ctx.fillStyle = skin; ctx.strokeStyle = '#333'; ctx.lineWidth = 1;
|
|
ctx.fillRect(-5, -8, 10, 8);
|
|
|
|
// ---- HEAD ----
|
|
ctx.fillStyle = skin; ctx.strokeStyle = '#333'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.ellipse(0, -18, 15, 14, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
|
|
// Wrinkles
|
|
ctx.strokeStyle = skinShade; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(-8, -24); ctx.quadraticCurveTo(-4, -26, 0, -24); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(0, -24); ctx.quadraticCurveTo(4, -26, 8, -24); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-6, -27); ctx.quadraticCurveTo(0, -29, 6, -27); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-12, -14); ctx.lineTo(-9, -12); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(12, -14); ctx.lineTo(9, -12); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-5, -14); ctx.quadraticCurveTo(-6, -9, -4, -6); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(5, -14); ctx.quadraticCurveTo(6, -9, 4, -6); ctx.stroke();
|
|
|
|
// Nose (big old person nose)
|
|
ctx.fillStyle = skinShade; ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-1, -18); ctx.quadraticCurveTo(-5, -10, -3, -8);
|
|
ctx.quadraticCurveTo(0, -6, 3, -8); ctx.quadraticCurveTo(5, -10, 1, -18);
|
|
ctx.closePath(); ctx.fill(); ctx.stroke();
|
|
|
|
// Ears (big)
|
|
ctx.fillStyle = skin; ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.ellipse(-14, -16, 4, 6, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath(); ctx.ellipse(14, -16, 4, 6, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
if (type !== 1) {
|
|
ctx.strokeStyle = '#bbb'; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(-15, -18); ctx.lineTo(-18, -20); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(15, -18); ctx.lineTo(18, -20); ctx.stroke();
|
|
}
|
|
|
|
// ---- HAIR ----
|
|
if (type === 0) {
|
|
// Beret + gray sides
|
|
ctx.fillStyle = '#333'; ctx.strokeStyle = '#222'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.ellipse(0, -30, 16, 6, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath(); ctx.arc(0, -30, 3, 0, Math.PI*2); ctx.fill();
|
|
ctx.fillStyle = '#bbb';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-13, -22); ctx.quadraticCurveTo(-15, -18, -14, -12);
|
|
ctx.quadraticCurveTo(-13, -16, -11, -22); ctx.closePath(); ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(13, -22); ctx.quadraticCurveTo(15, -18, 14, -12);
|
|
ctx.quadraticCurveTo(13, -16, 11, -22); ctx.closePath(); ctx.fill();
|
|
}
|
|
if (type === 1) {
|
|
// Curly grandma hair
|
|
ctx.fillStyle = '#c8c0d8'; ctx.strokeStyle = '#a098b8'; ctx.lineWidth = 1;
|
|
for (let row = 0; row < 3; row++) {
|
|
for (let i = -3; i <= 3; i++) {
|
|
ctx.beginPath(); ctx.arc(i*5, -28-row*4, 5-row, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
if (type === 2) {
|
|
// Bald + liver spots + comb-over
|
|
ctx.fillStyle = skinShade;
|
|
ctx.beginPath(); ctx.arc(-5, -28, 2, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(7, -26, 1.5, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(2, -30, 1.5, 0, Math.PI*2); ctx.fill();
|
|
ctx.strokeStyle = '#aaa'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(-3, -31); ctx.quadraticCurveTo(5, -38, 12, -28); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(0, -32); ctx.quadraticCurveTo(8, -40, 15, -27); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(3, -31); ctx.quadraticCurveTo(10, -38, 16, -26); ctx.stroke();
|
|
}
|
|
|
|
// ---- GLASSES ----
|
|
if (type === 0 || type === 2) {
|
|
ctx.strokeStyle = type === 0 ? '#8B7355' : '#555'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.roundRect(-12, -20, 10, 8, 2); ctx.stroke();
|
|
ctx.beginPath(); ctx.roundRect(2, -20, 10, 8, 2); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-2, -16); ctx.lineTo(2, -16); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-12, -17); ctx.lineTo(-14, -16); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(12, -17); ctx.lineTo(14, -16); ctx.stroke();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
|
ctx.fillRect(-10, -19, 3, 3); ctx.fillRect(4, -19, 3, 3);
|
|
}
|
|
|
|
// ---- EYES (terrified!) ----
|
|
ctx.fillStyle = '#fff'; ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.ellipse(-7, -17, 4, 5, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath(); ctx.ellipse(7, -17, 4, 5, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
// Pupils (terrified)
|
|
ctx.fillStyle = '#222';
|
|
ctx.beginPath(); ctx.arc(-7, -16, 2.5, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(7, -16, 2.5, 0, Math.PI*2); ctx.fill();
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath(); ctx.arc(-6, -17, 1, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(8, -17, 1, 0, Math.PI*2); ctx.fill();
|
|
// Eyebrows (raised in terror)
|
|
ctx.strokeStyle = type === 1 ? '#a098b0' : '#888'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.moveTo(-11, -23); ctx.quadraticCurveTo(-7, -26, -3, -23); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(3, -23); ctx.quadraticCurveTo(7, -26, 11, -23); ctx.stroke();
|
|
|
|
// ---- MOUTH (screaming) ----
|
|
ctx.fillStyle = '#4a0000'; ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.ellipse(0, -6, 6, 5+Math.sin(t/100)*1, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = '#cc5555';
|
|
ctx.beginPath(); ctx.ellipse(0, -3, 3, 2, 0, 0, Math.PI); ctx.fill();
|
|
// Missing teeth
|
|
ctx.fillStyle = '#eee';
|
|
ctx.fillRect(-4, -10, 2, 3); ctx.fillRect(3, -10, 2, 3);
|
|
|
|
// ---- SWEAT DROPS ----
|
|
ctx.fillStyle = 'rgba(100,180,255,0.6)';
|
|
const sw = Math.sin(t/200) * 3;
|
|
ctx.beginPath(); ctx.moveTo(-14, -22); ctx.quadraticCurveTo(-16, -18+sw, -14, -16+sw);
|
|
ctx.quadraticCurveTo(-12, -18+sw, -14, -22); ctx.fill();
|
|
ctx.beginPath(); ctx.moveTo(14, -20); ctx.quadraticCurveTo(16, -16+sw, 14, -14+sw);
|
|
ctx.quadraticCurveTo(12, -16+sw, 14, -20); ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ========== BLOOD SYSTEM ==========
|
|
function spawnBlood(x, y, amount, velScale) {
|
|
// Cap to avoid overflow
|
|
const actualAmount = Math.min(amount, 20);
|
|
for (let i = 0; i < actualAmount; i++) {
|
|
bloods.push({
|
|
x, y,
|
|
vx: (Math.random() - 0.5) * 12 * velScale,
|
|
vy: (Math.random() - 0.8) * 10 * velScale,
|
|
size: 2 + Math.random() * 5,
|
|
life: 1,
|
|
gravity: 0.3 + Math.random() * 0.2,
|
|
type: Math.random() > 0.3 ? 'drop' : 'splash',
|
|
});
|
|
}
|
|
// Cap bloods
|
|
if (bloods.length > 200) bloods.splice(0, bloods.length - 200);
|
|
// Blood pool on ground
|
|
bloodPools.push({
|
|
x: x + (Math.random()-0.5)*40,
|
|
z: 5 + Math.random() * 15,
|
|
size: 15 + Math.random() * 25,
|
|
alpha: 0.7 + Math.random() * 0.3,
|
|
});
|
|
// Cap blood pools
|
|
if (bloodPools.length > 30) bloodPools.splice(0, bloodPools.length - 30);
|
|
}
|
|
|
|
function updateBloods() {
|
|
for (let i = bloods.length - 1; i >= 0; i--) {
|
|
const b = bloods[i];
|
|
b.x += b.vx;
|
|
b.y += b.vy;
|
|
b.vy += b.gravity;
|
|
b.life -= 0.03;
|
|
if (b.life <= 0 || b.y > H + 20) bloods.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function drawBloods() {
|
|
for (const b of bloods) {
|
|
ctx.globalAlpha = b.life;
|
|
if (b.type === 'drop') {
|
|
ctx.fillStyle = `rgb(${150 + Math.random()*60}, 0, 0)`;
|
|
ctx.beginPath();
|
|
ctx.arc(b.x, b.y, b.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else {
|
|
ctx.fillStyle = '#8B0000';
|
|
ctx.fillRect(b.x - b.size/2, b.y - 1, b.size, 2 + Math.random()*2);
|
|
}
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ========== FIREBALLS ==========
|
|
function spawnFireball() {
|
|
if (fireballs.length >= 15) return; // cap fireballs
|
|
fireballs.push({
|
|
x: mouseX,
|
|
y: mouseY,
|
|
z: 0,
|
|
startX: mouseX,
|
|
startY: mouseY,
|
|
targetX: W / 2,
|
|
life: 1,
|
|
trail: [],
|
|
});
|
|
}
|
|
|
|
function updateFireballs() {
|
|
const horizon = H * 0.3;
|
|
for (let i = fireballs.length - 1; i >= 0; i--) {
|
|
const fb = fireballs[i];
|
|
fb.z += 18 + speed * 4; // speed of the fireball
|
|
|
|
// Perspective interpolation: as z grows, x/y move toward vanishing point
|
|
const progress = Math.min(fb.z / 1000, 1);
|
|
const perspX = fb.startX + (fb.targetX - fb.startX) * progress;
|
|
const perspY = fb.startY + (horizon - fb.startY) * progress;
|
|
const perspScale = Math.max(0.1, 1 - progress);
|
|
fb.screenX = perspX;
|
|
fb.screenY = perspY;
|
|
fb.screenScale = perspScale;
|
|
|
|
// Trail
|
|
fb.trail.push({ x: perspX, y: perspY, s: perspScale, life: 1 });
|
|
if (fb.trail.length > 6) fb.trail.shift();
|
|
for (const tr of fb.trail) tr.life -= 0.12;
|
|
|
|
// Fire particles along the way
|
|
if (Math.random() < 0.3) {
|
|
particles.push({
|
|
x: perspX + (Math.random() - 0.5) * 15 * perspScale,
|
|
y: perspY + (Math.random() - 0.5) * 15 * perspScale,
|
|
vx: (Math.random() - 0.5) * 3,
|
|
vy: (Math.random() - 0.5) * 3 - 1,
|
|
size: 2 + Math.random() * 4 * perspScale,
|
|
life: 0.5 + Math.random() * 0.3,
|
|
color: Math.random() > 0.5 ? '#ff6600' : '#ffcc00',
|
|
gravity: -0.1,
|
|
});
|
|
}
|
|
|
|
// Check collision with enemies
|
|
for (const e of enemies) {
|
|
if (e.dead) continue;
|
|
const zDiff = Math.abs(fb.z - e.z);
|
|
if (zDiff < 80) {
|
|
const { sx, sy, size } = getEnemyScreen(e);
|
|
const dx = fb.screenX - sx;
|
|
const dy = fb.screenY - sy;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 80 * size + 30) {
|
|
// HIT!
|
|
e.dead = true;
|
|
e.hitAnim = 0;
|
|
kills++;
|
|
combo++;
|
|
comboTimer = 120;
|
|
if (combo > maxCombo) maxCombo = combo;
|
|
let pts = 10 * combo;
|
|
score += pts;
|
|
|
|
// FIRE + BLOOD explosion
|
|
spawnBlood(sx, sy, 25 + combo * 5, 1 + combo * 0.2);
|
|
spawnBlood(sx, sy, 15, 1.5);
|
|
// Extra fire explosion
|
|
for (let j = 0; j < 8; j++) {
|
|
particles.push({
|
|
x: sx, y: sy,
|
|
vx: (Math.random() - 0.5) * 14,
|
|
vy: (Math.random() - 0.5) * 14,
|
|
size: 3 + Math.random() * 6,
|
|
life: 0.8 + Math.random() * 0.4,
|
|
color: ['#ff4400', '#ff8800', '#ffcc00', '#fff'][Math.floor(Math.random() * 4)],
|
|
gravity: -0.05,
|
|
});
|
|
}
|
|
|
|
shakeTime = 8 + Math.min(combo * 2, 15);
|
|
flashAlpha = 0.15;
|
|
spawnHitText(sx, sy - 30, pts, combo);
|
|
|
|
// Teeth / bone particles
|
|
for (let j = 0; j < 3 + combo; j++) {
|
|
particles.push({
|
|
x: sx, y: sy,
|
|
vx: (Math.random() - 0.5) * 10,
|
|
vy: -3 - Math.random() * 8,
|
|
size: 2 + Math.random() * 3,
|
|
life: 1,
|
|
color: Math.random() > 0.5 ? '#fff' : '#ddd',
|
|
gravity: 0.4,
|
|
});
|
|
}
|
|
|
|
// Remove fireball on hit
|
|
fireballs.splice(i, 1);
|
|
i--;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove if past horizon
|
|
if (fb.z > 1050) {
|
|
fireballs.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawFireballs() {
|
|
for (const fb of fireballs) {
|
|
// Draw trail (simple circles, no gradients)
|
|
for (const tr of fb.trail) {
|
|
if (tr.life <= 0) continue;
|
|
ctx.globalAlpha = tr.life * 0.5;
|
|
const r = 6 * tr.s;
|
|
ctx.fillStyle = '#ff6600';
|
|
ctx.beginPath();
|
|
ctx.arc(tr.x, tr.y, r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Draw main fireball (simple layered circles)
|
|
ctx.globalAlpha = 0.3;
|
|
const r = 14 * fb.screenScale;
|
|
ctx.fillStyle = '#ff4400';
|
|
ctx.beginPath();
|
|
ctx.arc(fb.screenX, fb.screenY, r * 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = '#ff8800';
|
|
ctx.beginPath();
|
|
ctx.arc(fb.screenX, fb.screenY, r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#ffee55';
|
|
ctx.beginPath();
|
|
ctx.arc(fb.screenX, fb.screenY, r * 0.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ========== WEAPON POWER-UPS ==========
|
|
function spawnPowerup() {
|
|
const lane = (Math.random() - 0.5) * W * 0.6;
|
|
powerups.push({
|
|
x: W/2 + lane,
|
|
z: 1000,
|
|
yOff: (Math.random() - 0.5) * 20,
|
|
bobPhase: Math.random() * Math.PI * 2,
|
|
collected: false,
|
|
});
|
|
}
|
|
|
|
function updatePowerups() {
|
|
// Don't spawn during boss fights
|
|
const anyBossUp = (bossActive && !bossDefeated) || (minibossActive && !minibossDefeated) || (miniboss2Active && !miniboss2Defeated);
|
|
if (!anyBossUp && !doubleFireball) {
|
|
powerupSpawnTimer++;
|
|
if (powerupSpawnTimer >= 400 + Math.random() * 200) { // every ~7-10 seconds
|
|
powerupSpawnTimer = 0;
|
|
spawnPowerup();
|
|
}
|
|
}
|
|
|
|
const baseSpeed = 3 + speed * 1.5;
|
|
for (let i = powerups.length - 1; i >= 0; i--) {
|
|
const pu = powerups[i];
|
|
pu.z -= baseSpeed;
|
|
|
|
// Check collection by fireball
|
|
const { sx, sy, size } = getPowerupScreen(pu);
|
|
for (let j = fireballs.length - 1; j >= 0; j--) {
|
|
const fb = fireballs[j];
|
|
const zDiff = Math.abs(fb.z - pu.z);
|
|
if (zDiff < 80) {
|
|
const dx = fb.screenX - sx;
|
|
const dy = fb.screenY - sy;
|
|
if (Math.sqrt(dx*dx + dy*dy) < 70 * size + 30) {
|
|
collectPowerup(sx, sy);
|
|
powerups.splice(i, 1);
|
|
fireballs.splice(j, 1);
|
|
i = Math.min(i, powerups.length);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Passed the player without collection
|
|
if (pu.z < -50) {
|
|
powerups.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Double fireball timer
|
|
if (doubleFireball) {
|
|
doubleFireballTimer--;
|
|
if (doubleFireballTimer <= 0) {
|
|
doubleFireball = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectPowerup(sx, sy) {
|
|
doubleFireball = true;
|
|
doubleFireballTimer = DOUBLE_FB_DURATION;
|
|
|
|
// Flashy pickup effect
|
|
shakeTime = 5;
|
|
for (let i = 0; i < 20; i++) {
|
|
particles.push({
|
|
x: sx, y: sy,
|
|
vx: (Math.random()-0.5) * 12,
|
|
vy: (Math.random()-0.5) * 12,
|
|
size: 3 + Math.random() * 5,
|
|
life: 0.8 + Math.random() * 0.3,
|
|
color: ['#ff4400','#ff8800','#ffcc00','#fff','#00aaff'][Math.floor(Math.random()*5)],
|
|
gravity: -0.05,
|
|
});
|
|
}
|
|
spawnHitText(sx, sy - 30, 0, 0);
|
|
hitTexts[hitTexts.length - 1].text = 'DOUBLE FEU!';
|
|
hitTexts[hitTexts.length - 1].color = '#00ccff';
|
|
}
|
|
|
|
function getPowerupScreen(pu) {
|
|
const horizon = H * 0.3;
|
|
const perspScale = Math.max(0.05, 1 - pu.z / 1000);
|
|
const screenX = W/2 + (pu.x - W/2) * perspScale;
|
|
const screenY = horizon + (1 - perspScale) * 0.1 * H + perspScale * (H * 0.35) + pu.yOff * perspScale;
|
|
return { sx: screenX, sy: screenY, size: perspScale };
|
|
}
|
|
|
|
function drawPowerups() {
|
|
const t = Date.now();
|
|
for (const pu of powerups) {
|
|
const { sx, sy, size } = getPowerupScreen(pu);
|
|
if (size < 0.05) continue;
|
|
|
|
const bob = Math.sin(t / 200 + pu.bobPhase) * 5 * size;
|
|
const s = size * 25;
|
|
|
|
ctx.save();
|
|
ctx.translate(sx, sy + bob);
|
|
|
|
// Glow (simple)
|
|
ctx.globalAlpha = 0.25;
|
|
ctx.fillStyle = '#ff8800';
|
|
ctx.beginPath(); ctx.arc(0, 0, s * 1.8, 0, Math.PI * 2); ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Ring
|
|
ctx.strokeStyle = '#ffaa00';
|
|
ctx.lineWidth = 2 * size;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, s * 1.2, s * 0.6, t / 500, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Core
|
|
ctx.fillStyle = '#ff8800';
|
|
ctx.beginPath(); ctx.arc(0, 0, s, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = '#ffcc00';
|
|
ctx.beginPath(); ctx.arc(0, 0, s * 0.6, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// "x2" text
|
|
ctx.fillStyle = '#fff'; ctx.strokeStyle = '#000';
|
|
ctx.lineWidth = 2 * size;
|
|
ctx.font = `bold ${14 * size}px sans-serif`;
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.strokeText('x2', 0, 0);
|
|
ctx.fillText('x2', 0, 0);
|
|
ctx.textBaseline = 'alphabetic';
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// ========== MINI-BOSS SYSTEM ==========
|
|
function activateMiniboss() {
|
|
minibossActive = true;
|
|
minibossHP = minibossMaxHP;
|
|
minibossX = 0;
|
|
minibossHitFlash = 0;
|
|
minibossLasers = [];
|
|
minibossLaserTimer = 0;
|
|
minibossYells = [];
|
|
minibossDefeated = false;
|
|
minibossExplodeTimer = 0;
|
|
enemies = [];
|
|
}
|
|
|
|
function updateMiniboss() {
|
|
if (!minibossActive || minibossDefeated) return;
|
|
const t = Date.now();
|
|
minibossX = Math.sin(t / 1200) * W * 0.25;
|
|
if (minibossHitFlash > 0) minibossHitFlash -= 0.05;
|
|
|
|
minibossLaserTimer++;
|
|
if (minibossLaserTimer >= 70) {
|
|
minibossLaserTimer = 0;
|
|
fireMinibossLaser();
|
|
}
|
|
|
|
for (let i = minibossLasers.length - 1; i >= 0; i--) {
|
|
const las = minibossLasers[i];
|
|
las.progress += 0.04;
|
|
las.life -= 0.015;
|
|
if (las.progress > 0.85 && !las.hit) {
|
|
const dx = mouseX - las.targetX;
|
|
const dy = mouseY - las.targetY;
|
|
if (Math.sqrt(dx*dx + dy*dy) < 80) {
|
|
las.hit = true;
|
|
lives--;
|
|
combo = 0;
|
|
shakeTime = 12;
|
|
flashAlpha = 0.35;
|
|
document.getElementById('lives').textContent = '\u2764\ufe0f'.repeat(Math.max(0, lives));
|
|
if (lives <= 0) endGame();
|
|
}
|
|
}
|
|
if (las.life <= 0) minibossLasers.splice(i, 1);
|
|
}
|
|
|
|
for (let i = minibossYells.length - 1; i >= 0; i--) {
|
|
minibossYells[i].life -= 0.012;
|
|
minibossYells[i].y += minibossYells[i].vy;
|
|
minibossYells[i].scale += 0.01;
|
|
if (minibossYells[i].life <= 0) minibossYells.splice(i, 1);
|
|
}
|
|
|
|
// Check fireball hits
|
|
const horizon = H * 0.3;
|
|
const mbOff1 = megaFightTriggered && bossActive ? -W * 0.25 : 0;
|
|
const mbCX = W/2 + minibossX + mbOff1;
|
|
const mbCY = horizon - 5;
|
|
for (let i = fireballs.length - 1; i >= 0; i--) {
|
|
const fb = fireballs[i];
|
|
if (fb.z > 800) {
|
|
const dx = fb.screenX - mbCX;
|
|
const dy = fb.screenY - mbCY;
|
|
if (Math.sqrt(dx*dx + dy*dy) < 90) {
|
|
minibossHP--;
|
|
minibossHitFlash = 1;
|
|
shakeTime = 10;
|
|
flashAlpha = 0.15;
|
|
score += 50;
|
|
spawnHitText(mbCX, mbCY - 50, 50, 0);
|
|
for (let j = 0; j < 8; j++) {
|
|
particles.push({
|
|
x: fb.screenX, y: fb.screenY,
|
|
vx: (Math.random()-0.5)*12, vy: (Math.random()-0.5)*12,
|
|
size: 3+Math.random()*5, life: 0.6+Math.random()*0.4,
|
|
color: ['#00ff44','#44ff00','#88ff00'][Math.floor(Math.random()*3)],
|
|
gravity: -0.05,
|
|
});
|
|
}
|
|
fireballs.splice(i, 1);
|
|
if (minibossHP <= 0) defeatMiniboss();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fireMinibossLaser() {
|
|
if (minibossLasers.length >= 8) return; // cap lasers
|
|
const horizon = H * 0.3;
|
|
const mbOff1 = megaFightTriggered && bossActive ? -W * 0.25 : 0;
|
|
const mbCX = W/2 + minibossX + mbOff1;
|
|
const mbCY = horizon - 5;
|
|
const tx = mouseX + (Math.random()-0.5)*120;
|
|
const ty = mouseY + (Math.random()-0.5)*80;
|
|
|
|
minibossLasers.push({
|
|
startX: mbCX - 20, startY: mbCY + 8,
|
|
targetX: tx, targetY: ty,
|
|
progress: 0, life: 1, hit: false,
|
|
});
|
|
minibossLasers.push({
|
|
startX: mbCX + 18, startY: mbCY + 8,
|
|
targetX: tx + 15, targetY: ty,
|
|
progress: 0, life: 1, hit: false,
|
|
});
|
|
|
|
const phrases = [
|
|
"ATTAQUE PRODUIT P\u00c9RIM\u00c9 !",
|
|
"P\u00c9RIM\u00c9 DEPUIS 2019 !",
|
|
"DLC D\u00c9PASS\u00c9E !",
|
|
"PRODUIT P\u00c9RIM\u00c9 !!",
|
|
"RAPPEL PRODUIT !",
|
|
];
|
|
minibossYells.push({
|
|
x: mbCX + (Math.random()-0.5)*100,
|
|
y: mbCY - 70 - Math.random()*30,
|
|
vy: -1.2 - Math.random(),
|
|
life: 1, scale: 1,
|
|
text: phrases[Math.floor(Math.random()*phrases.length)],
|
|
});
|
|
}
|
|
|
|
function defeatMiniboss() {
|
|
minibossDefeated = true;
|
|
minibossExplodeTimer = 120;
|
|
score += 2500;
|
|
lives = 5;
|
|
const horizon = H * 0.3;
|
|
spawnBlood(W/2 + minibossX, horizon - 5, 60, 2.5);
|
|
// After miniboss1 defeated, miniboss2 spawns 100m later
|
|
nextMiniboss2Distance = distance + 100;
|
|
nextMinibossDistance = Infinity; // wait for full cycle
|
|
}
|
|
|
|
function updateMinibossExplosion() {
|
|
if (!minibossDefeated || minibossExplodeTimer <= 0) return;
|
|
minibossExplodeTimer--;
|
|
const horizon = H * 0.3;
|
|
const mbOff1 = megaFightTriggered && bossActive ? -W * 0.25 : 0;
|
|
const mbCX = W/2 + minibossX + mbOff1;
|
|
const mbCY = horizon - 5;
|
|
|
|
if (minibossExplodeTimer % 5 === 0) {
|
|
const ex = mbCX + (Math.random()-0.5)*160;
|
|
const ey = mbCY + (Math.random()-0.5)*120;
|
|
shakeTime = 4;
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: ex, y: ey,
|
|
vx: (Math.random()-0.5)*14, vy: (Math.random()-0.5)*14,
|
|
size: 4+Math.random()*7, life: 0.5+Math.random()*0.4,
|
|
color: ['#00ff44','#88ff00','#ffaa00','#fff','#8B0000'][Math.floor(Math.random()*5)],
|
|
gravity: 0.1,
|
|
});
|
|
}
|
|
spawnBlood(ex, ey, 8, 1.8);
|
|
}
|
|
|
|
if (minibossExplodeTimer <= 0) {
|
|
minibossActive = false;
|
|
minibossDefeated = false;
|
|
minibossLasers = [];
|
|
minibossYells = [];
|
|
spawnRate = Math.max(20, 80 - distance / 5);
|
|
}
|
|
}
|
|
|
|
function drawMiniboss() {
|
|
if (!minibossActive) return;
|
|
const horizon = H * 0.3;
|
|
const mbOff1 = megaFightTriggered && bossActive ? -W * 0.25 : 0;
|
|
const mbCX = W/2 + minibossX + mbOff1;
|
|
const mbCY = horizon - 5;
|
|
const t = Date.now();
|
|
|
|
ctx.save();
|
|
if (minibossDefeated) {
|
|
ctx.globalAlpha = Math.max(0, minibossExplodeTimer / 120);
|
|
ctx.translate((Math.random()-0.5)*8, (Math.random()-0.5)*8);
|
|
}
|
|
|
|
// Green aura (simple circle, no gradient)
|
|
ctx.fillStyle = 'rgba(0,100,0,0.12)';
|
|
ctx.beginPath(); ctx.arc(mbCX, mbCY, 110, 0, Math.PI*2); ctx.fill();
|
|
|
|
// Face
|
|
const mbSize = 90 + Math.sin(t/500)*6;
|
|
if (minibossLoaded) {
|
|
if (minibossHitFlash > 0.5) ctx.filter = `brightness(${1+minibossHitFlash*2})`;
|
|
ctx.drawImage(minibossImg, mbCX-mbSize/2, mbCY-mbSize/2, mbSize, mbSize);
|
|
ctx.filter = 'none';
|
|
} else {
|
|
ctx.fillStyle = minibossHitFlash > 0.5 ? '#fff' : '#d0c090';
|
|
ctx.beginPath(); ctx.arc(mbCX, mbCY, mbSize/2, 0, Math.PI*2); ctx.fill();
|
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.stroke();
|
|
ctx.fillStyle = '#333'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText('MINI-BOSS', mbCX, mbCY+5);
|
|
}
|
|
|
|
// Green glowing eyes (simple circles)
|
|
const eyeGlow = 0.4 + Math.sin(t/180)*0.2;
|
|
const leyX = mbCX - mbSize*0.16, leyY = mbCY - mbSize*0.02;
|
|
const reyX = mbCX + mbSize*0.14, reyY = mbCY - mbSize*0.02;
|
|
ctx.fillStyle = `rgba(0,255,0,${eyeGlow})`;
|
|
ctx.beginPath(); ctx.arc(leyX, leyY, 8, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(reyX, reyY, 8, 0, Math.PI*2); ctx.fill();
|
|
|
|
// HP Bar
|
|
if (!minibossDefeated) {
|
|
const barW = 150, barH = 10;
|
|
const barX = mbCX-barW/2, barY = mbCY-mbSize/2-22;
|
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
ctx.fillRect(barX-2, barY-2, barW+4, barH+4);
|
|
const hpR = minibossHP / minibossMaxHP;
|
|
const hpG = ctx.createLinearGradient(barX, 0, barX+barW*hpR, 0);
|
|
hpG.addColorStop(0, '#00cc00'); hpG.addColorStop(1, '#44ff00');
|
|
ctx.fillStyle = hpG;
|
|
ctx.fillRect(barX, barY, barW*hpR, barH);
|
|
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1;
|
|
ctx.strokeRect(barX, barY, barW, barH);
|
|
ctx.fillStyle = '#fff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText(`${minibossHP} / ${minibossMaxHP}`, mbCX, barY+barH-1);
|
|
ctx.fillStyle = '#44ff00'; ctx.font = 'bold 14px sans-serif';
|
|
ctx.fillText('Rouxcmoute', mbCX, barY-6);
|
|
}
|
|
|
|
// Lasers (GREEN) - optimized: no radial gradients
|
|
for (const las of minibossLasers) {
|
|
const prog = Math.min(las.progress, 1);
|
|
const cx = las.startX + (las.targetX-las.startX)*prog;
|
|
const cy = las.startY + (las.targetY-las.startY)*prog;
|
|
ctx.globalAlpha = las.life;
|
|
ctx.strokeStyle = 'rgba(0,255,0,0.3)'; ctx.lineWidth = 10;
|
|
ctx.beginPath(); ctx.moveTo(las.startX, las.startY); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.strokeStyle = '#00ff00'; ctx.lineWidth = 4;
|
|
ctx.beginPath(); ctx.moveTo(las.startX, las.startY); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.strokeStyle = '#aaffaa'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(las.startX, las.startY); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.fillStyle = 'rgba(100,255,100,0.6)';
|
|
ctx.beginPath(); ctx.arc(cx, cy, 14, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Yells
|
|
for (const yell of minibossYells) {
|
|
ctx.globalAlpha = yell.life;
|
|
ctx.fillStyle = '#00dd00'; ctx.strokeStyle = '#003300'; ctx.lineWidth = 3;
|
|
ctx.font = `bold ${16*yell.scale}px sans-serif`; ctx.textAlign = 'center';
|
|
ctx.strokeText(yell.text, yell.x, yell.y);
|
|
ctx.fillText(yell.text, yell.x, yell.y);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
ctx.restore();
|
|
}
|
|
|
|
// ========== MINI-BOSS 2 SYSTEM ==========
|
|
function activateMiniboss2() {
|
|
miniboss2Active = true;
|
|
miniboss2HP = miniboss2MaxHP;
|
|
miniboss2X = 0;
|
|
miniboss2HitFlash = 0;
|
|
miniboss2Lasers = [];
|
|
miniboss2LaserTimer = 0;
|
|
miniboss2Yells = [];
|
|
miniboss2Defeated = false;
|
|
miniboss2ExplodeTimer = 0;
|
|
if (!megaFightTriggered || (!bossActive && !minibossActive)) enemies = [];
|
|
}
|
|
|
|
function updateMiniboss2() {
|
|
if (!miniboss2Active || miniboss2Defeated) return;
|
|
const t = Date.now();
|
|
miniboss2X = Math.sin(t / 1000) * W * 0.22;
|
|
if (miniboss2HitFlash > 0) miniboss2HitFlash -= 0.05;
|
|
|
|
miniboss2LaserTimer++;
|
|
if (miniboss2LaserTimer >= 80) {
|
|
miniboss2LaserTimer = 0;
|
|
fireMiniboss2Laser();
|
|
}
|
|
|
|
for (let i = miniboss2Lasers.length - 1; i >= 0; i--) {
|
|
const las = miniboss2Lasers[i];
|
|
las.progress += 0.038;
|
|
las.life -= 0.015;
|
|
if (las.progress > 0.85 && !las.hit) {
|
|
const dx = mouseX - las.targetX;
|
|
const dy = mouseY - las.targetY;
|
|
if (Math.sqrt(dx*dx + dy*dy) < 80) {
|
|
las.hit = true;
|
|
lives--;
|
|
combo = 0;
|
|
shakeTime = 12;
|
|
flashAlpha = 0.35;
|
|
document.getElementById('lives').textContent = '\u2764\ufe0f'.repeat(Math.max(0, lives));
|
|
if (lives <= 0) endGame();
|
|
}
|
|
}
|
|
if (las.life <= 0) miniboss2Lasers.splice(i, 1);
|
|
}
|
|
|
|
for (let i = miniboss2Yells.length - 1; i >= 0; i--) {
|
|
miniboss2Yells[i].life -= 0.012;
|
|
miniboss2Yells[i].y += miniboss2Yells[i].vy;
|
|
miniboss2Yells[i].scale += 0.01;
|
|
if (miniboss2Yells[i].life <= 0) miniboss2Yells.splice(i, 1);
|
|
}
|
|
|
|
// Fireball hits
|
|
const horizon = H * 0.3;
|
|
const mb2Offset = megaFightTriggered && bossActive ? W * 0.25 : 0;
|
|
const mb2CX = W/2 + miniboss2X + mb2Offset;
|
|
const mb2CY = horizon - 5;
|
|
for (let i = fireballs.length - 1; i >= 0; i--) {
|
|
const fb = fireballs[i];
|
|
if (fb.z > 800) {
|
|
const dx = fb.screenX - mb2CX;
|
|
const dy = fb.screenY - mb2CY;
|
|
if (Math.sqrt(dx*dx + dy*dy) < 90) {
|
|
miniboss2HP--;
|
|
miniboss2HitFlash = 1;
|
|
shakeTime = 10;
|
|
flashAlpha = 0.15;
|
|
score += 50;
|
|
spawnHitText(mb2CX, mb2CY - 50, 50, 0);
|
|
for (let j = 0; j < 8; j++) {
|
|
particles.push({
|
|
x: fb.screenX, y: fb.screenY,
|
|
vx: (Math.random()-0.5)*12, vy: (Math.random()-0.5)*12,
|
|
size: 3+Math.random()*5, life: 0.6+Math.random()*0.4,
|
|
color: ['#4488ff','#2266ff','#88bbff'][Math.floor(Math.random()*3)],
|
|
gravity: -0.05,
|
|
});
|
|
}
|
|
fireballs.splice(i, 1);
|
|
if (miniboss2HP <= 0) defeatMiniboss2();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fireMiniboss2Laser() {
|
|
if (miniboss2Lasers.length >= 8) return; // cap lasers
|
|
const horizon = H * 0.3;
|
|
const mb2Offset = megaFightTriggered && bossActive ? W * 0.25 : 0;
|
|
const mb2CX = W/2 + miniboss2X + mb2Offset;
|
|
const mb2CY = horizon - 5;
|
|
const tx = mouseX + (Math.random()-0.5)*120;
|
|
const ty = mouseY + (Math.random()-0.5)*80;
|
|
|
|
miniboss2Lasers.push({
|
|
startX: mb2CX - 18, startY: mb2CY + 8,
|
|
targetX: tx, targetY: ty,
|
|
progress: 0, life: 1, hit: false,
|
|
});
|
|
miniboss2Lasers.push({
|
|
startX: mb2CX + 16, startY: mb2CY + 8,
|
|
targetX: tx + 15, targetY: ty,
|
|
progress: 0, life: 1, hit: false,
|
|
});
|
|
|
|
const phrases = [
|
|
"SI T'AS BESOIN DE RIEN...",
|
|
"QU'EST-CE QUE \u00c7A PEUT TE FOUTRE ?",
|
|
"SI T'AS BESOIN DE RIEN !",
|
|
"M\u00caLE-TOI DE TES AFFAIRES !",
|
|
"\u00c7A PEUT TE FOUTRE !!",
|
|
];
|
|
miniboss2Yells.push({
|
|
x: mb2CX + (Math.random()-0.5)*100,
|
|
y: mb2CY - 70 - Math.random()*30,
|
|
vy: -1.2 - Math.random(),
|
|
life: 1, scale: 1,
|
|
text: phrases[Math.floor(Math.random()*phrases.length)],
|
|
});
|
|
}
|
|
|
|
function defeatMiniboss2() {
|
|
miniboss2Defeated = true;
|
|
miniboss2ExplodeTimer = 120;
|
|
score += 3000;
|
|
lives = 5;
|
|
const horizon = H * 0.3;
|
|
const mb2Offset = megaFightTriggered && bossActive ? W * 0.25 : 0;
|
|
spawnBlood(W/2 + miniboss2X + mb2Offset, horizon - 5, 60, 2.5);
|
|
// After miniboss2 defeated, boss spawns 150m later
|
|
nextBossDistance = distance + 150;
|
|
nextMiniboss2Distance = Infinity; // wait for full cycle
|
|
}
|
|
|
|
function updateMiniboss2Explosion() {
|
|
if (!miniboss2Defeated || miniboss2ExplodeTimer <= 0) return;
|
|
miniboss2ExplodeTimer--;
|
|
const horizon = H * 0.3;
|
|
const mb2Offset = megaFightTriggered && bossActive ? W * 0.25 : 0;
|
|
const mb2CX = W/2 + miniboss2X + mb2Offset;
|
|
const mb2CY = horizon - 5;
|
|
|
|
if (miniboss2ExplodeTimer % 5 === 0) {
|
|
const ex = mb2CX + (Math.random()-0.5)*160;
|
|
const ey = mb2CY + (Math.random()-0.5)*120;
|
|
shakeTime = 4;
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: ex, y: ey,
|
|
vx: (Math.random()-0.5)*14, vy: (Math.random()-0.5)*14,
|
|
size: 4+Math.random()*7, life: 0.5+Math.random()*0.4,
|
|
color: ['#4488ff','#88bbff','#ffaa00','#fff','#8B0000'][Math.floor(Math.random()*5)],
|
|
gravity: 0.1,
|
|
});
|
|
}
|
|
spawnBlood(ex, ey, 8, 1.8);
|
|
}
|
|
|
|
if (miniboss2ExplodeTimer <= 0) {
|
|
miniboss2Active = false;
|
|
miniboss2Defeated = false;
|
|
miniboss2Lasers = [];
|
|
miniboss2Yells = [];
|
|
if (!bossActive && !minibossActive) {
|
|
spawnRate = Math.max(20, 80 - distance / 5);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawMiniboss2() {
|
|
if (!miniboss2Active) return;
|
|
const horizon = H * 0.3;
|
|
const mb2Offset = megaFightTriggered && bossActive ? W * 0.25 : 0;
|
|
const mb2CX = W/2 + miniboss2X + mb2Offset;
|
|
const mb2CY = horizon - 5;
|
|
const t = Date.now();
|
|
|
|
ctx.save();
|
|
if (miniboss2Defeated) {
|
|
ctx.globalAlpha = Math.max(0, miniboss2ExplodeTimer / 120);
|
|
ctx.translate((Math.random()-0.5)*8, (Math.random()-0.5)*8);
|
|
}
|
|
|
|
// Blue aura (simple circle)
|
|
ctx.fillStyle = 'rgba(0,50,150,0.12)';
|
|
ctx.beginPath(); ctx.arc(mb2CX, mb2CY, 110, 0, Math.PI*2); ctx.fill();
|
|
|
|
// Face
|
|
const mb2Size = 90 + Math.sin(t/450)*6;
|
|
if (miniboss2Loaded) {
|
|
if (miniboss2HitFlash > 0.5) ctx.filter = `brightness(${1+miniboss2HitFlash*2})`;
|
|
ctx.drawImage(miniboss2Img, mb2CX-mb2Size/2, mb2CY-mb2Size/2, mb2Size, mb2Size);
|
|
ctx.filter = 'none';
|
|
} else {
|
|
ctx.fillStyle = miniboss2HitFlash > 0.5 ? '#fff' : '#c0a080';
|
|
ctx.beginPath(); ctx.arc(mb2CX, mb2CY, mb2Size/2, 0, Math.PI*2); ctx.fill();
|
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.stroke();
|
|
ctx.fillStyle = '#333'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText('MINI-BOSS 2', mb2CX, mb2CY+5);
|
|
}
|
|
|
|
// Blue glowing eyes (simple circles)
|
|
const eyeGlow = 0.4 + Math.sin(t/180)*0.2;
|
|
const leyX = mb2CX - mb2Size*0.15, leyY = mb2CY - mb2Size*0.01;
|
|
const reyX = mb2CX + mb2Size*0.13, reyY = mb2CY - mb2Size*0.01;
|
|
ctx.fillStyle = `rgba(50,100,255,${eyeGlow})`;
|
|
ctx.beginPath(); ctx.arc(leyX, leyY, 8, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(reyX, reyY, 8, 0, Math.PI*2); ctx.fill();
|
|
|
|
// HP Bar
|
|
if (!miniboss2Defeated) {
|
|
const barW = 150, barH = 10;
|
|
const barX = mb2CX-barW/2, barY = mb2CY-mb2Size/2-22;
|
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
ctx.fillRect(barX-2, barY-2, barW+4, barH+4);
|
|
const hpR = miniboss2HP / miniboss2MaxHP;
|
|
const hpG = ctx.createLinearGradient(barX, 0, barX+barW*hpR, 0);
|
|
hpG.addColorStop(0, '#2266ff'); hpG.addColorStop(1, '#44aaff');
|
|
ctx.fillStyle = hpG;
|
|
ctx.fillRect(barX, barY, barW*hpR, barH);
|
|
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1;
|
|
ctx.strokeRect(barX, barY, barW, barH);
|
|
ctx.fillStyle = '#fff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText(`${miniboss2HP} / ${miniboss2MaxHP}`, mb2CX, barY+barH-1);
|
|
ctx.fillStyle = '#44aaff'; ctx.font = 'bold 14px sans-serif';
|
|
ctx.fillText('Gregounet', mb2CX, barY-6);
|
|
}
|
|
|
|
// Lasers (BLUE) - optimized: no radial gradients
|
|
for (const las of miniboss2Lasers) {
|
|
const prog = Math.min(las.progress, 1);
|
|
const cx = las.startX + (las.targetX-las.startX)*prog;
|
|
const cy = las.startY + (las.targetY-las.startY)*prog;
|
|
ctx.globalAlpha = las.life;
|
|
ctx.strokeStyle = 'rgba(50,100,255,0.3)'; ctx.lineWidth = 10;
|
|
ctx.beginPath(); ctx.moveTo(las.startX, las.startY); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.strokeStyle = '#4488ff'; ctx.lineWidth = 4;
|
|
ctx.beginPath(); ctx.moveTo(las.startX, las.startY); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.strokeStyle = '#aaccff'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(las.startX, las.startY); ctx.lineTo(cx, cy); ctx.stroke();
|
|
ctx.fillStyle = 'rgba(100,150,255,0.6)';
|
|
ctx.beginPath(); ctx.arc(cx, cy, 14, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Yells
|
|
for (const yell of miniboss2Yells) {
|
|
ctx.globalAlpha = yell.life;
|
|
ctx.fillStyle = '#4488ff'; ctx.strokeStyle = '#001144'; ctx.lineWidth = 3;
|
|
ctx.font = `bold ${15*yell.scale}px sans-serif`; ctx.textAlign = 'center';
|
|
ctx.strokeText(yell.text, yell.x, yell.y);
|
|
ctx.fillText(yell.text, yell.x, yell.y);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
ctx.restore();
|
|
}
|
|
|
|
// ========== BOSS SYSTEM ==========
|
|
function activateBoss() {
|
|
bossActive = true;
|
|
bossHP = bossMaxHP;
|
|
bossX = 0;
|
|
bossHitFlash = 0;
|
|
bossLasers = [];
|
|
bossLaserTimer = 0;
|
|
bossYellTimer = 0;
|
|
bossYells = [];
|
|
bossDefeated = false;
|
|
bossExplodeTimer = 0;
|
|
// Clear remaining enemies
|
|
enemies = [];
|
|
}
|
|
|
|
function updateBoss() {
|
|
if (!bossActive || bossDefeated) return;
|
|
|
|
const t = Date.now();
|
|
// Boss sways left/right
|
|
bossX = Math.sin(t / 1500) * W * 0.2;
|
|
|
|
if (bossHitFlash > 0) bossHitFlash -= 0.05;
|
|
|
|
// Boss fires lasers periodically
|
|
bossLaserTimer++;
|
|
if (bossLaserTimer >= 90) { // every ~1.5s
|
|
bossLaserTimer = 0;
|
|
fireBossLaser();
|
|
}
|
|
|
|
// Update lasers
|
|
for (let i = bossLasers.length - 1; i >= 0; i--) {
|
|
const las = bossLasers[i];
|
|
las.progress += 0.035;
|
|
las.life -= 0.015;
|
|
|
|
// Check if laser hits player (when progress is near 1 = reached player area)
|
|
if (las.progress > 0.85 && !las.hit) {
|
|
const dx = mouseX - las.targetX;
|
|
const dy = mouseY - las.targetY;
|
|
if (Math.sqrt(dx * dx + dy * dy) < 80) {
|
|
las.hit = true;
|
|
lives--;
|
|
combo = 0;
|
|
shakeTime = 15;
|
|
flashAlpha = 0.4;
|
|
document.getElementById('lives').textContent = '\u2764\ufe0f'.repeat(Math.max(0, lives));
|
|
if (lives <= 0) endGame();
|
|
}
|
|
}
|
|
|
|
if (las.life <= 0) bossLasers.splice(i, 1);
|
|
}
|
|
|
|
// Update yells
|
|
for (let i = bossYells.length - 1; i >= 0; i--) {
|
|
bossYells[i].life -= 0.012;
|
|
bossYells[i].y += bossYells[i].vy;
|
|
bossYells[i].scale += 0.01;
|
|
if (bossYells[i].life <= 0) bossYells.splice(i, 1);
|
|
}
|
|
|
|
// Check fireball hits on boss
|
|
const horizon = H * 0.3;
|
|
const bossCX = W / 2 + bossX;
|
|
const bossCY = horizon - 10;
|
|
|
|
for (let i = fireballs.length - 1; i >= 0; i--) {
|
|
const fb = fireballs[i];
|
|
if (fb.z > 800) {
|
|
const dx = fb.screenX - bossCX;
|
|
const dy = fb.screenY - bossCY;
|
|
if (Math.sqrt(dx * dx + dy * dy) < 100) {
|
|
// HIT BOSS
|
|
bossHP--;
|
|
bossHitFlash = 1;
|
|
shakeTime = 12;
|
|
flashAlpha = 0.2;
|
|
score += 100;
|
|
spawnHitText(bossCX, bossCY - 60, 100, 0);
|
|
|
|
// Fire explosion on boss
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: fb.screenX, y: fb.screenY,
|
|
vx: (Math.random() - 0.5) * 12,
|
|
vy: (Math.random() - 0.5) * 12,
|
|
size: 3 + Math.random() * 5,
|
|
life: 0.6 + Math.random() * 0.4,
|
|
color: ['#ff4400', '#ff8800', '#ffcc00'][Math.floor(Math.random() * 3)],
|
|
gravity: -0.05,
|
|
});
|
|
}
|
|
|
|
fireballs.splice(i, 1);
|
|
|
|
if (bossHP <= 0) {
|
|
defeatBoss();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fireBossLaser() {
|
|
if (bossLasers.length >= 10) return; // cap lasers
|
|
const horizon = H * 0.3;
|
|
const bossCX = W / 2 + bossX;
|
|
const bossCY = horizon - 10;
|
|
const tx = mouseX + (Math.random() - 0.5) * 100;
|
|
const ty = mouseY + (Math.random() - 0.5) * 60;
|
|
|
|
bossLasers.push({
|
|
startX: bossCX - 25,
|
|
startY: bossCY + 10,
|
|
targetX: tx,
|
|
targetY: ty,
|
|
progress: 0,
|
|
life: 1,
|
|
hit: false,
|
|
side: 'left',
|
|
});
|
|
bossLasers.push({
|
|
startX: bossCX + 25,
|
|
startY: bossCY + 10,
|
|
targetX: tx + 20,
|
|
targetY: ty,
|
|
progress: 0,
|
|
life: 1,
|
|
hit: false,
|
|
side: 'right',
|
|
});
|
|
|
|
// "T'ES VIRE!" yell
|
|
bossYellTimer++;
|
|
bossYells.push({
|
|
x: bossCX + (Math.random() - 0.5) * 100,
|
|
y: bossCY - 80 - Math.random() * 40,
|
|
vy: -1 - Math.random(),
|
|
life: 1,
|
|
scale: 1,
|
|
text: ["T'ES VIR\u00c9 !", "D\u00c9GAGE !", "TU ES FINI !", "VIR\u00c9 !!!", "DEHORS !"][Math.floor(Math.random() * 5)],
|
|
});
|
|
}
|
|
|
|
function defeatBoss() {
|
|
bossDefeated = true;
|
|
bossExplodeTimer = 180; // 3 seconds of explosions
|
|
score += 5000;
|
|
lives = 5;
|
|
// Massive blood + fire
|
|
const horizon = H * 0.3;
|
|
const bossCX = W / 2 + bossX;
|
|
spawnBlood(bossCX, horizon + 20, 80, 3);
|
|
// After boss defeated, restart cycle: miniboss1 in 150m
|
|
nextMinibossDistance = distance + 150;
|
|
nextBossDistance = Infinity; // wait for full cycle
|
|
}
|
|
|
|
function updateBossExplosion() {
|
|
if (!bossDefeated || bossExplodeTimer <= 0) return;
|
|
bossExplodeTimer--;
|
|
|
|
const horizon = H * 0.3;
|
|
const bossCX = W / 2 + bossX;
|
|
const bossCY = horizon - 10;
|
|
|
|
// Random explosions all over the boss area
|
|
if (bossExplodeTimer % 4 === 0) {
|
|
const ex = bossCX + (Math.random() - 0.5) * 200;
|
|
const ey = bossCY + (Math.random() - 0.5) * 150;
|
|
shakeTime = 5;
|
|
for (let j = 0; j < 8; j++) {
|
|
particles.push({
|
|
x: ex, y: ey,
|
|
vx: (Math.random() - 0.5) * 15,
|
|
vy: (Math.random() - 0.5) * 15,
|
|
size: 4 + Math.random() * 8,
|
|
life: 0.6 + Math.random() * 0.5,
|
|
color: ['#ff2200', '#ff6600', '#ffaa00', '#fff', '#8B0000'][Math.floor(Math.random() * 5)],
|
|
gravity: 0.1,
|
|
});
|
|
}
|
|
spawnBlood(ex, ey, 10, 2);
|
|
}
|
|
|
|
// Boss defeated, resume normal gameplay
|
|
if (bossExplodeTimer <= 0) {
|
|
bossActive = false;
|
|
bossDefeated = false;
|
|
bossLasers = [];
|
|
bossYells = [];
|
|
// Enemies start spawning again
|
|
spawnRate = Math.max(20, 80 - distance / 5);
|
|
}
|
|
}
|
|
|
|
function drawBoss() {
|
|
if (!bossActive) return;
|
|
|
|
const horizon = H * 0.3;
|
|
const bossCX = W / 2 + bossX;
|
|
const bossCY = horizon - 10;
|
|
const t = Date.now();
|
|
|
|
ctx.save();
|
|
|
|
if (bossDefeated) {
|
|
// Shake and fade during explosion
|
|
ctx.globalAlpha = Math.max(0, bossExplodeTimer / 180);
|
|
ctx.translate((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10);
|
|
}
|
|
|
|
// Boss dark aura (simple circle)
|
|
ctx.fillStyle = 'rgba(100,0,0,0.12)';
|
|
ctx.beginPath();
|
|
ctx.arc(bossCX, bossCY, 130, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Draw boss face (PNG or fallback)
|
|
const bossSize = 110 + Math.sin(t / 500) * 8; // breathing, smaller = further back
|
|
if (bossLoaded) {
|
|
if (bossHitFlash > 0.5) {
|
|
// Flash white when hit
|
|
ctx.filter = `brightness(${1 + bossHitFlash * 2})`;
|
|
}
|
|
ctx.drawImage(bossImg, bossCX - bossSize / 2, bossCY - bossSize / 2, bossSize, bossSize);
|
|
ctx.filter = 'none';
|
|
} else {
|
|
// Fallback
|
|
ctx.fillStyle = bossHitFlash > 0.5 ? '#fff' : '#f0c090';
|
|
ctx.beginPath();
|
|
ctx.arc(bossCX, bossCY, bossSize / 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 3; ctx.stroke();
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = 'bold 24px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText('BOSS', bossCX, bossCY + 8);
|
|
}
|
|
|
|
// Red glowing eyes (simple circles)
|
|
const eyeGlow = 0.4 + Math.sin(t / 200) * 0.2;
|
|
const leyX = bossCX - bossSize * 0.16;
|
|
const leyY = bossCY - bossSize * 0.02;
|
|
const reyX = bossCX + bossSize * 0.14;
|
|
const reyY = bossCY - bossSize * 0.02;
|
|
ctx.fillStyle = `rgba(255,0,0,${eyeGlow})`;
|
|
ctx.beginPath(); ctx.arc(leyX, leyY, 10, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(reyX, reyY, 10, 0, Math.PI*2); ctx.fill();
|
|
|
|
// HP Bar
|
|
if (!bossDefeated) {
|
|
const barW = 200;
|
|
const barH = 12;
|
|
const barX = bossCX - barW / 2;
|
|
const barY = bossCY - bossSize / 2 - 25;
|
|
// Background
|
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
ctx.fillRect(barX - 2, barY - 2, barW + 4, barH + 4);
|
|
// HP fill
|
|
const hpRatio = bossHP / bossMaxHP;
|
|
const hpGrad = ctx.createLinearGradient(barX, 0, barX + barW * hpRatio, 0);
|
|
hpGrad.addColorStop(0, '#ff0000');
|
|
hpGrad.addColorStop(1, hpRatio > 0.5 ? '#ff4400' : '#ff0000');
|
|
ctx.fillStyle = hpGrad;
|
|
ctx.fillRect(barX, barY, barW * hpRatio, barH);
|
|
// Border
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(barX, barY, barW, barH);
|
|
// Text
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(`${bossHP} / ${bossMaxHP}`, bossCX, barY + barH - 2);
|
|
|
|
// Boss name
|
|
ctx.fillStyle = '#ff4444';
|
|
ctx.font = 'bold 18px sans-serif';
|
|
ctx.fillText('BOSS', bossCX, barY - 8);
|
|
}
|
|
|
|
// Draw lasers - optimized: no radial gradients, no particles in draw
|
|
for (const las of bossLasers) {
|
|
const prog = Math.min(las.progress, 1);
|
|
const currentX = las.startX + (las.targetX - las.startX) * prog;
|
|
const currentY = las.startY + (las.targetY - las.startY) * prog;
|
|
|
|
ctx.globalAlpha = las.life;
|
|
|
|
ctx.strokeStyle = 'rgba(255,0,0,0.3)';
|
|
ctx.lineWidth = 12;
|
|
ctx.beginPath();
|
|
ctx.moveTo(las.startX, las.startY);
|
|
ctx.lineTo(currentX, currentY);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = '#ff0000';
|
|
ctx.lineWidth = 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(las.startX, las.startY);
|
|
ctx.lineTo(currentX, currentY);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = '#ff8888';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(las.startX, las.startY);
|
|
ctx.lineTo(currentX, currentY);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = 'rgba(255,100,100,0.6)';
|
|
ctx.beginPath();
|
|
ctx.arc(currentX, currentY, 16, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Draw yells
|
|
for (const yell of bossYells) {
|
|
ctx.globalAlpha = yell.life;
|
|
ctx.fillStyle = '#ff0000';
|
|
ctx.strokeStyle = '#000';
|
|
ctx.lineWidth = 3;
|
|
ctx.font = `bold ${20 * yell.scale}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.strokeText(yell.text, yell.x, yell.y);
|
|
ctx.fillText(yell.text, yell.x, yell.y);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ========== CITY BUILDINGS (pre-rendered to offscreen canvas) ==========
|
|
let trees = [];
|
|
let buildingsCanvas = null; // offscreen canvas for near buildings
|
|
let buildingsFarCanvas = null; // offscreen canvas for far buildings
|
|
const CITY_WIDTH = 2000;
|
|
|
|
function generateCity() {
|
|
trees = [];
|
|
|
|
// Generate trees
|
|
for (let i = 0; i < 20; i++) {
|
|
trees.push({
|
|
xRatio: Math.random(),
|
|
side: Math.random() > 0.5 ? 1 : -1,
|
|
size: 0.7 + Math.random() * 0.5,
|
|
trunkH: 15 + Math.random() * 10,
|
|
leafR: 12 + Math.random() * 8,
|
|
leafShade: Math.floor(Math.random() * 40),
|
|
});
|
|
}
|
|
|
|
// Pre-render buildings to offscreen canvases
|
|
const skylineH = 300;
|
|
|
|
// Near buildings
|
|
buildingsCanvas = document.createElement('canvas');
|
|
buildingsCanvas.width = CITY_WIDTH;
|
|
buildingsCanvas.height = skylineH;
|
|
const bctx = buildingsCanvas.getContext('2d');
|
|
|
|
// Far buildings
|
|
buildingsFarCanvas = document.createElement('canvas');
|
|
buildingsFarCanvas.width = CITY_WIDTH;
|
|
buildingsFarCanvas.height = skylineH;
|
|
const fctx = buildingsFarCanvas.getContext('2d');
|
|
|
|
let bx = 0;
|
|
while (bx < CITY_WIDTH) {
|
|
const bw = 40 + Math.random() * 80;
|
|
const bh = 80 + Math.random() * 200;
|
|
const floors = Math.floor(bh / 25);
|
|
const windowCols = Math.floor(bw / 18);
|
|
const hue = Math.floor(Math.random() * 40) + 200;
|
|
const light = 25 + Math.floor(Math.random() * 20);
|
|
const color = `hsl(${hue}, ${10 + Math.random()*15}%, ${light}%)`;
|
|
const colorDark = `hsl(${hue}, ${8 + Math.random()*10}%, ${light - 8}%)`;
|
|
const roofType = Math.floor(Math.random() * 3);
|
|
const buildY = skylineH - bh;
|
|
|
|
// --- Near layer ---
|
|
bctx.fillStyle = color;
|
|
bctx.fillRect(bx, buildY, bw, bh);
|
|
bctx.fillStyle = colorDark;
|
|
bctx.fillRect(bx + bw - 4, buildY, 4, bh);
|
|
|
|
// Windows
|
|
const winW = 5, winH = 7;
|
|
const padX = (bw - windowCols * (winW + 6)) / 2;
|
|
for (let row = 0; row < floors; row++) {
|
|
for (let col = 0; col < windowCols; col++) {
|
|
const wx = bx + padX + col * (winW + 6) + 4;
|
|
const wy = buildY + 8 + row * (winH + 10);
|
|
if (wy > skylineH - 5) continue;
|
|
if (Math.random() > 0.4) {
|
|
bctx.fillStyle = `rgba(255, 220, 100, ${0.5 + Math.random() * 0.3})`;
|
|
} else {
|
|
bctx.fillStyle = 'rgba(30, 40, 60, 0.8)';
|
|
}
|
|
bctx.fillRect(wx, wy, winW, winH);
|
|
}
|
|
}
|
|
|
|
// Roof
|
|
if (roofType === 1) {
|
|
bctx.strokeStyle = '#555'; bctx.lineWidth = 1.5;
|
|
bctx.beginPath();
|
|
bctx.moveTo(bx + bw / 2, buildY);
|
|
bctx.lineTo(bx + bw / 2, buildY - 20);
|
|
bctx.stroke();
|
|
bctx.fillStyle = '#ff0000';
|
|
bctx.beginPath(); bctx.arc(bx + bw / 2, buildY - 20, 2, 0, Math.PI * 2); bctx.fill();
|
|
} else if (roofType === 2) {
|
|
bctx.fillStyle = '#4a4a50';
|
|
bctx.fillRect(bx + bw * 0.3, buildY - 12, bw * 0.4, 12);
|
|
bctx.fillStyle = '#555';
|
|
bctx.fillRect(bx + bw * 0.25, buildY - 14, bw * 0.5, 3);
|
|
}
|
|
|
|
// --- Far layer (simpler silhouette) ---
|
|
fctx.fillStyle = '#1a1e28';
|
|
fctx.fillRect(bx, skylineH - bh * 0.4, bw, bh * 0.4);
|
|
|
|
bx += bw + 2 + Math.random() * 5;
|
|
}
|
|
}
|
|
generateCity();
|
|
|
|
// ========== PSEUDO 3D GROUND (City road - deep perspective) ==========
|
|
function drawGround() {
|
|
const horizon = H * 0.3; // higher horizon = more depth
|
|
const t = Date.now();
|
|
const DEPTH = 1200; // deeper z range
|
|
|
|
// ---- SKY (bigger, more gradient layers) ----
|
|
const skyGrad = ctx.createLinearGradient(0, 0, 0, horizon);
|
|
skyGrad.addColorStop(0, '#0e1a30');
|
|
skyGrad.addColorStop(0.3, '#1a2a44');
|
|
skyGrad.addColorStop(0.6, '#2d3e5a');
|
|
skyGrad.addColorStop(0.85, '#4a5a72');
|
|
skyGrad.addColorStop(1, '#6a7a92');
|
|
ctx.fillStyle = skyGrad;
|
|
ctx.fillRect(0, 0, W, horizon);
|
|
|
|
// Clouds (simple, fast)
|
|
ctx.fillStyle = 'rgba(180,190,210,0.12)';
|
|
const cloudOff = (distance * 3) % (W * 2);
|
|
for (let i = 0; i < 6; i++) {
|
|
const cx = ((i * 300 - cloudOff) % (W + 400)) - 100;
|
|
const cy = 20 + i * 22 + Math.sin(i * 1.5) * 15;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, 80 + i * 10, 16, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// ---- BUILDINGS SKYLINE (pre-rendered, just scroll & blit) ----
|
|
const skylineH = horizon * 1.2;
|
|
|
|
// Far layer (parallax slow)
|
|
if (buildingsFarCanvas) {
|
|
const farScroll = (distance * 6) % CITY_WIDTH;
|
|
ctx.globalAlpha = 0.5;
|
|
const farH = skylineH * 0.5;
|
|
ctx.drawImage(buildingsFarCanvas, farScroll, 0, CITY_WIDTH - farScroll, 300, 0, horizon - farH, (CITY_WIDTH - farScroll) / CITY_WIDTH * W, farH);
|
|
ctx.drawImage(buildingsFarCanvas, 0, 0, farScroll, 300, (CITY_WIDTH - farScroll) / CITY_WIDTH * W, horizon - farH, farScroll / CITY_WIDTH * W, farH);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// Near layer (parallax faster)
|
|
if (buildingsCanvas) {
|
|
const nearScroll = (distance * 15) % CITY_WIDTH;
|
|
const nearH = skylineH;
|
|
ctx.drawImage(buildingsCanvas, nearScroll, 0, CITY_WIDTH - nearScroll, 300, 0, horizon - nearH, (CITY_WIDTH - nearScroll) / CITY_WIDTH * W, nearH);
|
|
ctx.drawImage(buildingsCanvas, 0, 0, nearScroll, 300, (CITY_WIDTH - nearScroll) / CITY_WIDTH * W, horizon - nearH, nearScroll / CITY_WIDTH * W, nearH);
|
|
}
|
|
|
|
// Atmospheric haze at horizon (simple)
|
|
ctx.fillStyle = 'rgba(75,85,105,0.3)';
|
|
ctx.fillRect(0, horizon - 15, W, 30);
|
|
|
|
// Horizon line
|
|
ctx.fillStyle = '#3a3a3a';
|
|
ctx.fillRect(0, horizon - 1, W, 3);
|
|
|
|
// ---- ROAD ----
|
|
const roadGrad = ctx.createLinearGradient(0, horizon, 0, H);
|
|
roadGrad.addColorStop(0, '#606060');
|
|
roadGrad.addColorStop(0.08, '#505050');
|
|
roadGrad.addColorStop(0.3, '#444444');
|
|
roadGrad.addColorStop(0.6, '#383838');
|
|
roadGrad.addColorStop(1, '#2a2a2a');
|
|
ctx.fillStyle = roadGrad;
|
|
ctx.fillRect(0, horizon, W, H - horizon);
|
|
|
|
const offset = (distance * 60) % 80;
|
|
|
|
// White edge lines (road borders)
|
|
for (let i = 0; i < 30; i++) {
|
|
const z = i * 28 + offset;
|
|
const pf = z / DEPTH;
|
|
if (pf > 1) continue;
|
|
const perspY = horizon + pf * (H - horizon);
|
|
const nextPY = horizon + ((z + 28) / DEPTH) * (H - horizon);
|
|
const rowH = Math.max(1, nextPY - perspY);
|
|
if (perspY < horizon || perspY > H) continue;
|
|
|
|
// Road edges converge toward vanishing point
|
|
const edgeDist = W * 0.48 * pf + W * 0.02;
|
|
const lineW = 1.5 + pf * 4;
|
|
|
|
ctx.fillStyle = `rgba(255,255,255,${0.08 + pf * 0.25})`;
|
|
ctx.fillRect(W / 2 - edgeDist - lineW / 2, perspY, lineW, rowH);
|
|
ctx.fillRect(W / 2 + edgeDist - lineW / 2, perspY, lineW, rowH);
|
|
}
|
|
|
|
// Center dashed yellow line
|
|
for (let i = 0; i < 25; i++) {
|
|
const z = (i * 45 + offset * 1.2) % (DEPTH + 100);
|
|
const pf = z / DEPTH;
|
|
if (pf > 1) continue;
|
|
const perspY = horizon + pf * (H - horizon);
|
|
if (perspY < horizon || perspY > H) continue;
|
|
const dashLen = 8 + pf * 18;
|
|
const dashW = 1.5 + pf * 4;
|
|
ctx.fillStyle = `rgba(255,220,80,${0.1 + pf * 0.35})`;
|
|
ctx.fillRect(W / 2 - dashW / 2, perspY, dashW, dashLen * 0.35);
|
|
}
|
|
|
|
// ---- TREES on road edges ----
|
|
for (const tree of trees) {
|
|
const z = ((tree.xRatio * DEPTH + offset * 0.6) % DEPTH);
|
|
const pf = z / DEPTH;
|
|
if (pf < 0.02 || pf > 0.95) continue;
|
|
const perspY = horizon + pf * (H - horizon);
|
|
const s = tree.size * (0.15 + pf * 1.4);
|
|
const edgeDist = W * 0.48 * pf + W * 0.02;
|
|
const tx = tree.side > 0 ? W / 2 + edgeDist + 18 * s : W / 2 - edgeDist - 18 * s;
|
|
|
|
// Depth fog on distant trees
|
|
const fogAlpha = pf < 0.15 ? pf / 0.15 : 1;
|
|
ctx.globalAlpha = fogAlpha;
|
|
|
|
ctx.fillStyle = '#5a3a1a';
|
|
ctx.fillRect(tx - 2 * s, perspY - tree.trunkH * s, 4 * s, tree.trunkH * s);
|
|
|
|
const lr = tree.leafR * s;
|
|
const lShade = tree.leafShade;
|
|
ctx.fillStyle = `rgb(${30 + lShade}, ${80 + lShade}, ${25 + lShade})`;
|
|
ctx.beginPath();
|
|
ctx.arc(tx, perspY - tree.trunkH * s - lr * 0.5, lr, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = `rgb(${40 + lShade}, ${95 + lShade}, ${30 + lShade})`;
|
|
ctx.beginPath();
|
|
ctx.arc(tx - lr * 0.4, perspY - tree.trunkH * s - lr * 0.3, lr * 0.7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(tx + lr * 0.5, perspY - tree.trunkH * s - lr * 0.6, lr * 0.6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ---- STREET LAMPS ----
|
|
for (let i = 0; i < 12; i++) {
|
|
const z = ((i * 100 + offset * 0.7) % DEPTH);
|
|
const pf = z / DEPTH;
|
|
if (pf < 0.03 || pf > 0.92) continue;
|
|
const perspY = horizon + pf * (H - horizon);
|
|
const s = 0.15 + pf * 1.2;
|
|
const edgeDist = W * 0.48 * pf + W * 0.02;
|
|
const side = i % 2 === 0 ? -1 : 1;
|
|
const lx = W / 2 + side * (edgeDist + 6 * s);
|
|
|
|
const fogAlpha = pf < 0.12 ? pf / 0.12 : 1;
|
|
ctx.globalAlpha = fogAlpha;
|
|
|
|
ctx.strokeStyle = '#666';
|
|
ctx.lineWidth = 2 * s;
|
|
ctx.beginPath();
|
|
ctx.moveTo(lx, perspY);
|
|
ctx.lineTo(lx, perspY - 35 * s);
|
|
ctx.stroke();
|
|
|
|
const dir = -side;
|
|
ctx.beginPath();
|
|
ctx.moveTo(lx, perspY - 35 * s);
|
|
ctx.quadraticCurveTo(lx + dir * 8 * s, perspY - 38 * s, lx + dir * 12 * s, perspY - 33 * s);
|
|
ctx.stroke();
|
|
|
|
const glowR = 15 * s;
|
|
const lg = ctx.createRadialGradient(lx + dir * 12 * s, perspY - 33 * s, 0, lx + dir * 12 * s, perspY - 33 * s, glowR);
|
|
lg.addColorStop(0, `rgba(255,230,150,${0.4 * fogAlpha})`);
|
|
lg.addColorStop(1, 'rgba(255,200,80,0)');
|
|
ctx.fillStyle = lg;
|
|
ctx.beginPath();
|
|
ctx.arc(lx + dir * 12 * s, perspY - 33 * s, glowR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ---- Blood pools on ground ----
|
|
for (const pool of bloodPools) {
|
|
const pScale = 1 - pool.z / 30;
|
|
if (pScale <= 0) continue;
|
|
const py = horizon + (1 - pScale) * (H - horizon) * 0.3 + H * 0.4;
|
|
ctx.globalAlpha = pool.alpha * pScale;
|
|
ctx.fillStyle = '#5a0000';
|
|
ctx.beginPath();
|
|
ctx.ellipse(pool.x, py, pool.size * (1 / pScale) * 0.5, pool.size * 0.2, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ========== ENEMIES ==========
|
|
function spawnEnemy() {
|
|
const type = Math.floor(Math.random() * 3);
|
|
const lane = (Math.random() - 0.5) * W * 0.7;
|
|
// Speed: some are slow (0.5x), normal (1x), or fast (1.8x)
|
|
const speedRoll = Math.random();
|
|
const enemySpeed = speedRoll < 0.25 ? 0.5 : speedRoll < 0.6 ? 1 : 1.8;
|
|
enemies.push({
|
|
x: W/2 + lane,
|
|
z: 1000,
|
|
type,
|
|
hp: 1,
|
|
hitAnim: 0,
|
|
dead: false,
|
|
yOff: (Math.random() - 0.5) * 30,
|
|
spd: enemySpeed,
|
|
});
|
|
}
|
|
|
|
function updateEnemies() {
|
|
const baseSpeed = 3 + speed * 1.5;
|
|
for (let i = enemies.length - 1; i >= 0; i--) {
|
|
const e = enemies[i];
|
|
e.z -= baseSpeed * (e.spd || 1);
|
|
|
|
if (e.dead) {
|
|
e.hitAnim += 0.05;
|
|
if (e.hitAnim > 1) {
|
|
enemies.splice(i, 1);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Passed the player
|
|
if (e.z < -50 && !e.dead) {
|
|
enemies.splice(i, 1);
|
|
lives--;
|
|
combo = 0;
|
|
shakeTime = 10;
|
|
flashAlpha = 0.3;
|
|
document.getElementById('lives').textContent = '\u2764\ufe0f'.repeat(Math.max(0, lives));
|
|
if (lives <= 0) endGame();
|
|
}
|
|
}
|
|
|
|
// Spawn (cap at 15 enemies max)
|
|
spawnTimer++;
|
|
if (spawnTimer >= spawnRate && enemies.length < 15) {
|
|
spawnTimer = 0;
|
|
spawnEnemy();
|
|
if (Math.random() < 0.3 && enemies.length < 15) spawnEnemy();
|
|
}
|
|
}
|
|
|
|
function getEnemyScreen(e) {
|
|
const horizon = H * 0.3;
|
|
const perspScale = Math.max(0.05, 1 - e.z / 1000);
|
|
const screenX = W/2 + (e.x - W/2) * perspScale;
|
|
const screenY = horizon + (1 - perspScale) * 0.05 * H + perspScale * (H * 0.5) + e.yOff * perspScale;
|
|
const size = perspScale;
|
|
return { sx: screenX, sy: screenY, size };
|
|
}
|
|
|
|
function drawEnemies() {
|
|
// Sort by z (far first)
|
|
const sorted = [...enemies].sort((a, b) => b.z - a.z);
|
|
for (const e of sorted) {
|
|
const { sx, sy, size } = getEnemyScreen(e);
|
|
if (size < 0.05) continue;
|
|
drawOldPerson(sx, sy, size, e.type, e.dead ? e.hitAnim : 0);
|
|
|
|
// Warning indicator when close
|
|
if (e.z < 150 && !e.dead) {
|
|
ctx.fillStyle = `rgba(255,0,0,${(1 - e.z/150) * 0.5})`;
|
|
ctx.font = `${20 * size}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('!', sx, sy - 40 * size);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== PUNCH (fires a fireball) ==========
|
|
function doPunch() {
|
|
if (punchTimer > 0) return;
|
|
punching = true;
|
|
punchTimer = 12;
|
|
|
|
// Launch fireball(s) from the fist
|
|
spawnFireball();
|
|
if (doubleFireball) {
|
|
// Second fireball with slight offset
|
|
const savedX = mouseX, savedY = mouseY;
|
|
mouseX += 30; mouseY -= 15;
|
|
spawnFireball();
|
|
mouseX = savedX; mouseY = savedY;
|
|
}
|
|
|
|
// Muzzle flash particles at fist
|
|
for (let i = 0; i < 10; i++) {
|
|
particles.push({
|
|
x: mouseX + (Math.random() - 0.5) * 10,
|
|
y: mouseY + (Math.random() - 0.5) * 10,
|
|
vx: (Math.random() - 0.5) * 8,
|
|
vy: (Math.random() - 0.5) * 8,
|
|
size: 3 + Math.random() * 5,
|
|
life: 0.4 + Math.random() * 0.3,
|
|
color: ['#ff4400', '#ff8800', '#ffcc00', '#fff'][Math.floor(Math.random() * 4)],
|
|
gravity: -0.05,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ========== HIT TEXT ==========
|
|
let hitTexts = [];
|
|
function spawnHitText(x, y, pts, cmb) {
|
|
let text = `+${pts}`;
|
|
let color = '#ff4444';
|
|
if (cmb >= 10) { text += ' MASSACRE!'; color = '#ff0000'; }
|
|
else if (cmb >= 7) { text += ' CARNAGE!'; color = '#ff2200'; }
|
|
else if (cmb >= 5) { text += ' BRUTAL!'; color = '#ff4400'; }
|
|
else if (cmb >= 3) { text += ' COMBO x' + cmb; color = '#ffd700'; }
|
|
hitTexts.push({ x, y, text, color, life: 1, vy: -2 });
|
|
if (hitTexts.length > 20) hitTexts.splice(0, hitTexts.length - 20);
|
|
}
|
|
|
|
function updateHitTexts() {
|
|
for (let i = hitTexts.length - 1; i >= 0; i--) {
|
|
const h = hitTexts[i];
|
|
h.y += h.vy;
|
|
h.life -= 0.02;
|
|
if (h.life <= 0) hitTexts.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function drawHitTexts() {
|
|
for (const h of hitTexts) {
|
|
ctx.globalAlpha = h.life;
|
|
ctx.fillStyle = h.color;
|
|
ctx.font = `bold ${22 + (1-h.life)*10}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(h.text, h.x, h.y);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ========== MISC PARTICLES ==========
|
|
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 += p.gravity;
|
|
p.life -= 0.025;
|
|
if (p.life <= 0) particles.splice(i, 1);
|
|
}
|
|
// Cap particles to prevent lag
|
|
if (particles.length > 150) particles.splice(0, particles.length - 150);
|
|
}
|
|
|
|
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, 0, Math.PI*2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ========== HUD ==========
|
|
function updateHUD() {
|
|
// Check for new super attack charges (every 250 pts)
|
|
const currentThreshold = Math.floor(score / 250);
|
|
if (currentThreshold > lastSuperThreshold) {
|
|
superAttackCharges = 1; // max 1, no stacking
|
|
lastSuperThreshold = currentThreshold;
|
|
}
|
|
|
|
document.getElementById('score').textContent = score;
|
|
document.getElementById('distance').textContent = Math.floor(distance) + 'm';
|
|
const comboEl = document.getElementById('combo');
|
|
if (combo >= 2) {
|
|
comboEl.textContent = `COMBO x${combo}`;
|
|
comboEl.style.fontSize = `${1.2 + combo * 0.05}rem`;
|
|
} else {
|
|
comboEl.textContent = '';
|
|
}
|
|
document.getElementById('lives').textContent = '\u2764\ufe0f'.repeat(Math.max(0, lives));
|
|
const superEl = document.getElementById('superHUD');
|
|
let superText = '';
|
|
if (doubleFireball) {
|
|
const secs = Math.ceil(doubleFireballTimer / 60);
|
|
superText += `\uD83D\uDD25x2 ${secs}s `;
|
|
}
|
|
if (superAttackCharges > 0) {
|
|
superText += '\uD83D\uDD25 [ESPACE]';
|
|
}
|
|
superEl.textContent = superText;
|
|
superEl.style.color = doubleFireball ? '#00ccff' : '#ffaa00';
|
|
}
|
|
|
|
// ========== SUPER ATTACK ==========
|
|
function doSuperAttack() {
|
|
if (superAttackCharges <= 0) return;
|
|
superAttackCharges--;
|
|
superAttackAnim = 60; // 1 second of animation
|
|
|
|
// Kill ALL enemies on screen
|
|
for (const e of enemies) {
|
|
if (e.dead) continue;
|
|
e.dead = true;
|
|
e.hitAnim = 0;
|
|
kills++;
|
|
score += 10;
|
|
const { sx, sy } = getEnemyScreen(e);
|
|
spawnBlood(sx, sy, 30, 2);
|
|
// Fire explosion on each enemy
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: sx, y: sy,
|
|
vx: (Math.random()-0.5)*15, vy: (Math.random()-0.5)*15,
|
|
size: 4+Math.random()*6, life: 0.7+Math.random()*0.4,
|
|
color: ['#ff4400','#ff8800','#ffcc00','#fff'][Math.floor(Math.random()*4)],
|
|
gravity: 0.05,
|
|
});
|
|
}
|
|
spawnHitText(sx, sy - 20, 10, 0);
|
|
}
|
|
|
|
// Damage boss -2 HP if active
|
|
if (bossActive && !bossDefeated) {
|
|
bossHP = Math.max(0, bossHP - 2);
|
|
bossHitFlash = 1;
|
|
shakeTime = 20;
|
|
const horizon = H * 0.3;
|
|
const bossCX = W/2 + bossX;
|
|
const bossCY = horizon - 10;
|
|
spawnHitText(bossCX, bossCY - 60, 200, 0);
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: bossCX + (Math.random()-0.5)*100,
|
|
y: bossCY + (Math.random()-0.5)*80,
|
|
vx: (Math.random()-0.5)*18, vy: (Math.random()-0.5)*18,
|
|
size: 5+Math.random()*8, life: 0.8+Math.random()*0.4,
|
|
color: ['#ff2200','#ff6600','#ffaa00','#fff'][Math.floor(Math.random()*4)],
|
|
gravity: 0.05,
|
|
});
|
|
}
|
|
if (bossHP <= 0) defeatBoss();
|
|
}
|
|
|
|
// Damage miniboss -2 HP if active
|
|
if (minibossActive && !minibossDefeated) {
|
|
minibossHP = Math.max(0, minibossHP - 2);
|
|
minibossHitFlash = 1;
|
|
shakeTime = 20;
|
|
const horizon = H * 0.3;
|
|
const mbOff1 = megaFightTriggered && bossActive ? -W * 0.25 : 0;
|
|
const mbCX = W/2 + minibossX + mbOff1;
|
|
const mbCY = horizon - 5;
|
|
spawnHitText(mbCX, mbCY - 50, 200, 0);
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: mbCX + (Math.random()-0.5)*80,
|
|
y: mbCY + (Math.random()-0.5)*60,
|
|
vx: (Math.random()-0.5)*16, vy: (Math.random()-0.5)*16,
|
|
size: 5+Math.random()*7, life: 0.8+Math.random()*0.4,
|
|
color: ['#00ff44','#88ff00','#ffaa00','#fff'][Math.floor(Math.random()*4)],
|
|
gravity: 0.05,
|
|
});
|
|
}
|
|
if (minibossHP <= 0) defeatMiniboss();
|
|
}
|
|
|
|
// Damage miniboss2 -2 HP if active
|
|
if (miniboss2Active && !miniboss2Defeated) {
|
|
miniboss2HP = Math.max(0, miniboss2HP - 2);
|
|
miniboss2HitFlash = 1;
|
|
shakeTime = 20;
|
|
const horizon = H * 0.3;
|
|
const mb2Off = megaFightTriggered && bossActive ? W * 0.25 : 0;
|
|
const mb2CX = W/2 + miniboss2X + mb2Off;
|
|
const mb2CY = horizon - 5;
|
|
spawnHitText(mb2CX, mb2CY - 50, 200, 0);
|
|
for (let j = 0; j < 10; j++) {
|
|
particles.push({
|
|
x: mb2CX + (Math.random()-0.5)*80,
|
|
y: mb2CY + (Math.random()-0.5)*60,
|
|
vx: (Math.random()-0.5)*16, vy: (Math.random()-0.5)*16,
|
|
size: 5+Math.random()*7, life: 0.8+Math.random()*0.4,
|
|
color: ['#4488ff','#88bbff','#ffaa00','#fff'][Math.floor(Math.random()*4)],
|
|
gravity: 0.05,
|
|
});
|
|
}
|
|
if (miniboss2HP <= 0) defeatMiniboss2();
|
|
}
|
|
|
|
// Big screen flash
|
|
flashAlpha = 0.6;
|
|
shakeTime = Math.max(shakeTime, 25);
|
|
}
|
|
|
|
// ========== GAME FLOW ==========
|
|
function startGame() {
|
|
document.getElementById('titleScreen').style.display = 'none';
|
|
document.getElementById('hud').style.display = 'flex';
|
|
resetGame();
|
|
gameRunning = true;
|
|
lastTime = 0;
|
|
accumulator = 0;
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
function resetGame() {
|
|
score = 0; kills = 0; combo = 0; maxCombo = 0; comboTimer = 0;
|
|
lives = 5; distance = 0; speed = 1; spawnTimer = 0; spawnRate = 80;
|
|
superAttackCharges = 0; lastSuperThreshold = 0; superAttackAnim = 0;
|
|
enemies = []; bloods = []; bloodPools = []; particles = []; hitTexts = []; fireballs = [];
|
|
powerups = []; powerupSpawnTimer = 0; doubleFireball = false; doubleFireballTimer = 0;
|
|
minibossActive = false; minibossHP = minibossMaxHP; minibossHitFlash = 0;
|
|
minibossLasers = []; minibossLaserTimer = 0; minibossYells = [];
|
|
minibossDefeated = false; minibossExplodeTimer = 0; nextMinibossDistance = 150;
|
|
bossActive = false; bossHP = bossMaxHP; bossHitFlash = 0; bossLasers = [];
|
|
bossLaserTimer = 0; bossYells = []; bossDefeated = false; bossExplodeTimer = 0;
|
|
nextBossDistance = Infinity; // boss waits for miniboss2 to be defeated
|
|
miniboss2Active = false; miniboss2HP = miniboss2MaxHP; miniboss2HitFlash = 0;
|
|
miniboss2Lasers = []; miniboss2LaserTimer = 0; miniboss2Yells = [];
|
|
miniboss2Defeated = false; miniboss2ExplodeTimer = 0; nextMiniboss2Distance = Infinity; // waits for miniboss1
|
|
megaFightTriggered = false; nextMegaFightDistance = 500;
|
|
shakeTime = 0; flashAlpha = 0; punching = false; punchTimer = 0;
|
|
updateHUD();
|
|
}
|
|
|
|
function endGame() {
|
|
gameRunning = false;
|
|
document.getElementById('finalScore').textContent = score;
|
|
document.getElementById('goStats').textContent =
|
|
`${kills} vieux \u00e9clat\u00e9s | Meilleur combo: x${maxCombo} | Distance: ${Math.floor(distance)}m`;
|
|
document.getElementById('gameOver').classList.add('show');
|
|
}
|
|
|
|
function restart() {
|
|
document.getElementById('gameOver').classList.remove('show');
|
|
document.getElementById('gameOver').querySelector('h2').textContent = 'K.O. TOTAL';
|
|
resetGame();
|
|
gameRunning = true;
|
|
lastTime = 0;
|
|
accumulator = 0;
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
// ========== MAIN LOOP (fixed 60fps logic) ==========
|
|
const TICK_RATE = 1000 / 60; // 16.67ms per logic tick
|
|
let lastTime = 0;
|
|
let accumulator = 0;
|
|
|
|
function tick() {
|
|
// All game logic runs at exactly 60fps
|
|
distance += 0.1 * speed;
|
|
speed = Math.min(8, 1 + distance / 200);
|
|
spawnRate = Math.max(25, 80 - distance / 5);
|
|
|
|
if (punchTimer > 0) punchTimer--;
|
|
if (punchTimer <= 0) punching = false;
|
|
|
|
if (comboTimer > 0) comboTimer--;
|
|
if (comboTimer <= 0 && combo > 0 && !punching) combo = 0;
|
|
|
|
// Mega fight: all 3 bosses at once
|
|
if (distance >= nextMegaFightDistance && !megaFightTriggered) {
|
|
megaFightTriggered = true;
|
|
enemies = [];
|
|
if (!minibossActive) { activateMiniboss(); }
|
|
if (!miniboss2Active) { activateMiniboss2(); }
|
|
if (!bossActive) { activateBoss(); }
|
|
}
|
|
|
|
// Normal solo triggers (only if no mega fight)
|
|
if (!megaFightTriggered) {
|
|
if (distance >= nextMinibossDistance && !minibossActive && !bossActive && !miniboss2Active) {
|
|
activateMiniboss();
|
|
}
|
|
if (distance >= nextMiniboss2Distance && !miniboss2Active && !bossActive && !minibossActive) {
|
|
activateMiniboss2();
|
|
}
|
|
if (distance >= nextBossDistance && !bossActive && !minibossActive && !miniboss2Active) {
|
|
activateBoss();
|
|
}
|
|
}
|
|
|
|
// Check mega fight over (all 3 defeated)
|
|
if (megaFightTriggered && !bossActive && !minibossActive && !miniboss2Active) {
|
|
megaFightTriggered = false;
|
|
nextMegaFightDistance = distance + MEGAFIGHT_INTERVAL;
|
|
spawnRate = Math.max(20, 80 - distance / 5);
|
|
}
|
|
|
|
// If any boss is active, stop spawning normal enemies
|
|
const anyBossUp = (bossActive && !bossDefeated) || (minibossActive && !minibossDefeated) || (miniboss2Active && !miniboss2Defeated);
|
|
if (anyBossUp) {
|
|
spawnRate = 99999;
|
|
speed = 0.5;
|
|
}
|
|
|
|
updateEnemies();
|
|
updateFireballs();
|
|
updatePowerups();
|
|
updateBloods();
|
|
updateParticles();
|
|
updateHitTexts();
|
|
updateMiniboss();
|
|
updateMinibossExplosion();
|
|
updateMiniboss2();
|
|
updateMiniboss2Explosion();
|
|
updateBoss();
|
|
updateBossExplosion();
|
|
|
|
if (shakeTime > 0) {
|
|
shakeX = (Math.random() - 0.5) * shakeTime * 1.5;
|
|
shakeY = (Math.random() - 0.5) * shakeTime * 1.5;
|
|
shakeTime--;
|
|
} else {
|
|
shakeX = 0; shakeY = 0;
|
|
}
|
|
|
|
if (superAttackAnim > 0) superAttackAnim--;
|
|
if (flashAlpha > 0) flashAlpha -= 0.02;
|
|
}
|
|
|
|
function draw() {
|
|
ctx.save();
|
|
ctx.translate(shakeX, shakeY);
|
|
|
|
drawGround();
|
|
drawMiniboss();
|
|
drawMiniboss2();
|
|
drawBoss();
|
|
drawPowerups();
|
|
drawEnemies();
|
|
drawFireballs();
|
|
drawBloods();
|
|
drawParticles();
|
|
drawHitTexts();
|
|
|
|
// Draw Gecko (player)
|
|
drawGecko(mouseX, mouseY, 1.3, punching || punchTimer > 8);
|
|
|
|
// Super attack animation (expanding fire ring - simple)
|
|
if (superAttackAnim > 0) {
|
|
const progress = 1 - superAttackAnim / 60;
|
|
const radius = progress * Math.max(W, H);
|
|
ctx.globalAlpha = (1 - progress) * 0.5;
|
|
ctx.strokeStyle = '#ff6600';
|
|
ctx.lineWidth = 20 * (1 - progress);
|
|
ctx.beginPath();
|
|
ctx.arc(mouseX, mouseY, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.strokeStyle = '#ffcc00';
|
|
ctx.lineWidth = 6 * (1 - progress);
|
|
ctx.beginPath();
|
|
ctx.arc(mouseX, mouseY, radius * 0.85, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
if (superAttackAnim > 30) {
|
|
ctx.fillStyle = '#ffd700'; ctx.strokeStyle = '#000'; ctx.lineWidth = 4;
|
|
ctx.font = `bold ${40 + progress * 20}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.globalAlpha = 1 - progress;
|
|
ctx.strokeText('SUPER ATTACK !', W/2, H/2 - 50);
|
|
ctx.fillText('SUPER ATTACK !', W/2, H/2 - 50);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
|
|
// Red flash overlay
|
|
if (flashAlpha > 0) {
|
|
ctx.fillStyle = `rgba(180, 0, 0, ${flashAlpha})`;
|
|
ctx.fillRect(-20, -20, W + 40, H + 40);
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
// Vignette (pre-rendered)
|
|
if (vignetteCanvas) {
|
|
ctx.drawImage(vignetteCanvas, 0, 0, W, H);
|
|
}
|
|
|
|
// Crosshair
|
|
ctx.strokeStyle = `rgba(255, 50, 50, ${punching ? 0.8 : 0.3})`;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.arc(mouseX, mouseY, 20, 0, Math.PI*2);
|
|
ctx.stroke();
|
|
|
|
updateHUD();
|
|
}
|
|
|
|
function loop(timestamp) {
|
|
if (!gameRunning) return;
|
|
|
|
if (lastTime === 0) lastTime = timestamp;
|
|
let delta = timestamp - lastTime;
|
|
lastTime = timestamp;
|
|
|
|
// Clamp delta to avoid spiral of death (e.g. tab was hidden)
|
|
if (delta > 200) delta = 200;
|
|
|
|
accumulator += delta;
|
|
|
|
// Run logic ticks at fixed 60fps
|
|
while (accumulator >= TICK_RATE) {
|
|
tick();
|
|
accumulator -= TICK_RATE;
|
|
}
|
|
|
|
// Render every frame
|
|
draw();
|
|
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
// ========== INPUT ==========
|
|
addEventListener('mousemove', e => {
|
|
mouseX = e.clientX;
|
|
mouseY = e.clientY;
|
|
});
|
|
|
|
addEventListener('mousedown', e => {
|
|
if (gameRunning) doPunch();
|
|
});
|
|
|
|
addEventListener('keydown', e => {
|
|
if (e.code === 'Space' && gameRunning) {
|
|
e.preventDefault();
|
|
doSuperAttack();
|
|
}
|
|
});
|
|
|
|
addEventListener('touchmove', e => {
|
|
e.preventDefault();
|
|
mouseX = e.touches[0].clientX;
|
|
mouseY = e.touches[0].clientY;
|
|
}, { passive: false });
|
|
|
|
addEventListener('touchstart', e => {
|
|
e.preventDefault();
|
|
mouseX = e.touches[0].clientX;
|
|
mouseY = e.touches[0].clientY;
|
|
if (gameRunning) doPunch();
|
|
}, { passive: false });
|
|
|
|
// Prevent context menu
|
|
addEventListener('contextmenu', e => e.preventDefault());
|
|
</script>
|
|
</body>
|
|
</html>
|