1986 lines
65 KiB
HTML
1986 lines
65 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Free For All - Arena Shooter v1.1</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
::-webkit-scrollbar { width: 8px; }
|
|
::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
|
|
::-webkit-scrollbar-thumb { background: #e94560; border-radius: 4px; }
|
|
::-webkit-scrollbar-thumb:hover { background: #ff5a7a; }
|
|
body {
|
|
background: #0a0a0a;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
cursor: crosshair;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
image-rendering: auto;
|
|
}
|
|
#menu {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
overflow-y: auto;
|
|
padding: 30px 20px;
|
|
z-index: 100;
|
|
}
|
|
#menu h1 {
|
|
font-size: 72px;
|
|
color: #e94560;
|
|
text-shadow: 0 0 30px rgba(233,69,96,0.5), 0 0 60px rgba(233,69,96,0.3);
|
|
margin-bottom: 10px;
|
|
letter-spacing: 8px;
|
|
font-weight: 900;
|
|
}
|
|
#menu .subtitle {
|
|
color: #aaa;
|
|
font-size: 18px;
|
|
margin-bottom: 40px;
|
|
letter-spacing: 4px;
|
|
}
|
|
.char-select {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
max-width: 900px;
|
|
}
|
|
.char-card {
|
|
width: 160px;
|
|
padding: 20px;
|
|
background: rgba(255,255,255,0.05);
|
|
border: 2px solid rgba(255,255,255,0.1);
|
|
border-radius: 15px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.char-card:hover {
|
|
background: rgba(233,69,96,0.15);
|
|
border-color: #e94560;
|
|
transform: translateY(-5px);
|
|
}
|
|
.char-card.selected {
|
|
background: rgba(233,69,96,0.25);
|
|
border-color: #e94560;
|
|
box-shadow: 0 0 20px rgba(233,69,96,0.3);
|
|
}
|
|
.char-card img {
|
|
width: 100px;
|
|
height: 100px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
margin-bottom: 10px;
|
|
border: 3px solid rgba(255,255,255,0.2);
|
|
}
|
|
.char-card.selected img {
|
|
border-color: #e94560;
|
|
}
|
|
.char-card .name {
|
|
color: #fff;
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
}
|
|
.map-card {
|
|
width: 180px;
|
|
padding: 10px;
|
|
background: rgba(255,255,255,0.05);
|
|
border: 2px solid rgba(255,255,255,0.1);
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.map-card:hover {
|
|
background: rgba(233,69,96,0.15);
|
|
border-color: #e94560;
|
|
transform: translateY(-3px);
|
|
}
|
|
.map-card.selected {
|
|
background: rgba(233,69,96,0.25);
|
|
border-color: #e94560;
|
|
box-shadow: 0 0 20px rgba(233,69,96,0.3);
|
|
}
|
|
.map-card canvas {
|
|
width: 160px;
|
|
height: 90px;
|
|
border-radius: 6px;
|
|
margin-bottom: 6px;
|
|
background: #0f0c29;
|
|
}
|
|
.map-card .name {
|
|
color: #fff;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
.bot-config {
|
|
display: flex;
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
align-items: center;
|
|
}
|
|
.bot-config label {
|
|
color: #ccc;
|
|
font-size: 16px;
|
|
}
|
|
.bot-config select {
|
|
padding: 8px 15px;
|
|
background: rgba(255,255,255,0.1);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
}
|
|
#startBtn {
|
|
padding: 15px 60px;
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #e94560, #c23152);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
letter-spacing: 3px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
#startBtn:hover {
|
|
transform: scale(1.05);
|
|
box-shadow: 0 0 30px rgba(233,69,96,0.5);
|
|
}
|
|
.controls-info {
|
|
margin-top: 30px;
|
|
color: #666;
|
|
font-size: 13px;
|
|
text-align: center;
|
|
line-height: 1.8;
|
|
}
|
|
.controls-info span {
|
|
color: #e94560;
|
|
font-weight: 600;
|
|
}
|
|
#hud {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0;
|
|
display: none;
|
|
justify-content: space-around;
|
|
padding: 10px 20px;
|
|
z-index: 50;
|
|
pointer-events: none;
|
|
}
|
|
.player-hud {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 14px;
|
|
background: rgba(0,0,0,0.6);
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
.player-hud .hud-name {
|
|
color: #fff;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
}
|
|
.player-hud .hud-hp {
|
|
width: 80px;
|
|
height: 8px;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.player-hud .hud-hp-bar {
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
transition: width 0.3s;
|
|
}
|
|
.player-hud .hud-ammo {
|
|
display: flex;
|
|
gap: 3px;
|
|
}
|
|
.player-hud .bullet {
|
|
width: 4px;
|
|
height: 12px;
|
|
background: #f1c40f;
|
|
border-radius: 2px;
|
|
}
|
|
.player-hud .bullet.empty {
|
|
background: rgba(255,255,255,0.15);
|
|
}
|
|
.player-hud .hud-score {
|
|
color: #e94560;
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
}
|
|
#gameOver {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.85);
|
|
display: none;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
}
|
|
#optionsBtn {
|
|
padding: 10px 40px;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
background: rgba(255,255,255,0.08);
|
|
color: #ccc;
|
|
border: 2px solid rgba(255,255,255,0.15);
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
letter-spacing: 2px;
|
|
transition: all 0.3s ease;
|
|
margin-bottom: 15px;
|
|
}
|
|
#optionsBtn:hover {
|
|
background: rgba(255,255,255,0.15);
|
|
border-color: #e94560;
|
|
color: #fff;
|
|
}
|
|
#optionsPanel {
|
|
display: none;
|
|
background: rgba(0,0,0,0.4);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 12px;
|
|
padding: 20px 30px;
|
|
margin-bottom: 20px;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
}
|
|
#optionsPanel.open { display: block; }
|
|
.option-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 14px;
|
|
gap: 15px;
|
|
}
|
|
.option-row:last-child { margin-bottom: 0; }
|
|
.option-row label {
|
|
color: #ccc;
|
|
font-size: 14px;
|
|
flex: 1;
|
|
}
|
|
.option-row .option-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.option-row input[type="range"] {
|
|
width: 120px;
|
|
accent-color: #e94560;
|
|
cursor: pointer;
|
|
}
|
|
.option-row .option-value {
|
|
color: #e94560;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
min-width: 40px;
|
|
text-align: center;
|
|
}
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 48px;
|
|
height: 26px;
|
|
cursor: pointer;
|
|
}
|
|
.toggle-switch input { display: none; }
|
|
.toggle-slider {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(255,255,255,0.15);
|
|
border-radius: 13px;
|
|
transition: 0.3s;
|
|
}
|
|
.toggle-slider::before {
|
|
content: '';
|
|
position: absolute;
|
|
width: 20px; height: 20px;
|
|
left: 3px; bottom: 3px;
|
|
background: #fff;
|
|
border-radius: 50%;
|
|
transition: 0.3s;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider {
|
|
background: #e94560;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider::before {
|
|
transform: translateX(22px);
|
|
}
|
|
#gameOver h2 {
|
|
font-size: 56px;
|
|
color: #e94560;
|
|
margin-bottom: 20px;
|
|
}
|
|
#gameOver .winner {
|
|
font-size: 28px;
|
|
color: #fff;
|
|
margin-bottom: 30px;
|
|
}
|
|
#gameOver button {
|
|
padding: 12px 40px;
|
|
font-size: 18px;
|
|
background: #e94560;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<canvas id="game"></canvas>
|
|
|
|
<div id="menu">
|
|
<h1>FREE FOR ALL</h1>
|
|
<div class="subtitle">ARENA SHOOTER — v1.1</div>
|
|
<p style="color:#ccc; margin-bottom:20px; font-size:16px;">Choisis ton personnage :</p>
|
|
<div class="char-select" id="charSelect"></div>
|
|
<p style="color:#ccc; margin-bottom:10px; margin-top:15px; font-size:16px;">Choisis la map :</p>
|
|
<div class="char-select" id="mapSelect"></div>
|
|
<div class="bot-config">
|
|
<label>Nombre de bots :</label>
|
|
<select id="botCount"></select>
|
|
</div>
|
|
<button id="optionsBtn" onclick="toggleOptions()">OPTIONS</button>
|
|
<div id="optionsPanel">
|
|
<div class="option-row">
|
|
<label>Points de vie</label>
|
|
<div class="option-control">
|
|
<input type="range" id="optHP" min="50" max="500" step="50" value="100">
|
|
<span class="option-value" id="optHPVal">100</span>
|
|
</div>
|
|
</div>
|
|
<div class="option-row">
|
|
<label>Balles par chargeur</label>
|
|
<div class="option-control">
|
|
<input type="range" id="optAmmo" min="2" max="30" step="2" value="6">
|
|
<span class="option-value" id="optAmmoVal">6</span>
|
|
</div>
|
|
</div>
|
|
<div class="option-row">
|
|
<label>Taille des balles</label>
|
|
<div class="option-control">
|
|
<input type="range" id="optBulletSize" min="3" max="15" step="1" value="3">
|
|
<span class="option-value" id="optBulletSizeVal">3</span>
|
|
</div>
|
|
</div>
|
|
<div class="option-row">
|
|
<label>Respawn munitions (sec)</label>
|
|
<div class="option-control">
|
|
<input type="range" id="optAmmoSpawn" min="1" max="15" step="1" value="4">
|
|
<span class="option-value" id="optAmmoSpawnVal">4</span>
|
|
</div>
|
|
</div>
|
|
<div class="option-row">
|
|
<label>Rebond des balles</label>
|
|
<div class="option-control">
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="optBounce">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button id="startBtn">JOUER</button>
|
|
<div class="controls-info">
|
|
<span>ZQSD</span> Déplacer | <span>ESPACE / Z</span> Sauter (x2 = double saut) | <span>SOURIS</span> Viser | <span>CLIC GAUCHE</span> Tirer<br>
|
|
<span>S / ↓</span> Traverser la plateforme | Score à <span>10 kills</span> pour gagner
|
|
</div>
|
|
</div>
|
|
|
|
<div id="hud"></div>
|
|
|
|
<div id="gameOver">
|
|
<h2 id="endTitle">GAME OVER</h2>
|
|
<div class="winner" id="winnerText"></div>
|
|
<button onclick="backToMenu()">RETOUR AU MENU</button>
|
|
</div>
|
|
|
|
<script>
|
|
// ============== CONFIGURATION ==============
|
|
const CANVAS = document.getElementById('game');
|
|
const CTX = CANVAS.getContext('2d');
|
|
const GRAVITY = 0.6;
|
|
const JUMP_FORCE = -13;
|
|
const MOVE_SPEED = 5;
|
|
const BULLET_SPEED = 14;
|
|
let MAX_AMMO = 6;
|
|
let MAX_HP = 100;
|
|
const BULLET_DAMAGE = 34;
|
|
const WIN_SCORE = 10;
|
|
let BULLET_SIZE = 3;
|
|
let BULLET_BOUNCE = false;
|
|
const RESPAWN_TIME = 2000;
|
|
let AMMO_SPAWN_INTERVAL = 4000;
|
|
|
|
let W, H;
|
|
let gameRunning = false;
|
|
let players = [];
|
|
let bullets = [];
|
|
let ammoPickups = [];
|
|
let particles = [];
|
|
let platforms = [];
|
|
let lastAmmoSpawn = 0;
|
|
let mouseX = 0, mouseY = 0;
|
|
let keys = {};
|
|
let animFrame = 0;
|
|
|
|
// ============== CHARACTERS ==============
|
|
// Pour ajouter un personnage : ajoute juste le fichier image dans img/
|
|
// et son nom ici (sans extension). Le reste est automatique !
|
|
// ==========================================
|
|
// LISTE DES PERSONNAGES - Ajoute ici le nom du fichier quand tu mets une image dans img/characters/
|
|
// ==========================================
|
|
const CHAR_FILES = ['nico.png', 'max.png', 'greg.png', 'thomas.png', 'micka.png', 'guigui.png', 'theo.png', 'anais.png', 'arnaud.png', 'jp.png'];
|
|
|
|
// Palette de couleurs auto-assignées
|
|
const COLOR_PALETTE = [
|
|
{ color: '#3498db', dark: '#2176ad' },
|
|
{ color: '#2ecc71', dark: '#1a9c54' },
|
|
{ color: '#e67e22', dark: '#c0690d' },
|
|
{ color: '#9b59b6', dark: '#7d3c98' },
|
|
{ color: '#e94560', dark: '#c13050' },
|
|
{ color: '#1abc9c', dark: '#148f77' },
|
|
{ color: '#f39c12', dark: '#d68910' },
|
|
{ color: '#00bcd4', dark: '#008c9e' },
|
|
];
|
|
|
|
const CHARACTERS = CHAR_FILES.map((file, i) => {
|
|
const name = file.replace(/\.[^.]+$/, '');
|
|
const capName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
const palette = COLOR_PALETTE[i % COLOR_PALETTE.length];
|
|
return { id: name, name: capName, img: null, src: 'img/characters/' + file, color: palette.color, colorDark: palette.dark };
|
|
});
|
|
|
|
let selectedChar = 0;
|
|
|
|
// ============== LOAD IMAGES ==============
|
|
let imagesLoaded = 0;
|
|
CHARACTERS.forEach((c, i) => {
|
|
c.img = new Image();
|
|
c.img.onload = () => {
|
|
imagesLoaded++;
|
|
if (imagesLoaded === CHARACTERS.length) buildMenu();
|
|
};
|
|
c.img.onerror = () => {
|
|
// Image failed, remove from list
|
|
imagesLoaded++;
|
|
if (imagesLoaded === CHARACTERS.length) buildMenu();
|
|
};
|
|
c.img.src = c.src;
|
|
});
|
|
|
|
// ============== MENU ==============
|
|
function buildMenu() {
|
|
const container = document.getElementById('charSelect');
|
|
container.innerHTML = '';
|
|
CHARACTERS.forEach((c, i) => {
|
|
if (!c.img || !c.img.naturalWidth) return; // skip failed images
|
|
const card = document.createElement('div');
|
|
card.className = 'char-card' + (i === 0 ? ' selected' : '');
|
|
card.innerHTML = `<img src="${c.src}"><div class="name">${c.name}</div>`;
|
|
card.onclick = () => {
|
|
document.querySelectorAll('.char-card').forEach(el => el.classList.remove('selected'));
|
|
card.classList.add('selected');
|
|
selectedChar = i;
|
|
};
|
|
container.appendChild(card);
|
|
});
|
|
|
|
// Build bot count select dynamically
|
|
const botSelect = document.getElementById('botCount');
|
|
botSelect.innerHTML = '';
|
|
const maxBots = CHARACTERS.length - 1;
|
|
for (let i = 1; i <= maxBots; i++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = i;
|
|
opt.textContent = i;
|
|
if (i === Math.min(2, maxBots)) opt.selected = true;
|
|
botSelect.appendChild(opt);
|
|
}
|
|
|
|
// Build map selection
|
|
buildMapSelect();
|
|
}
|
|
|
|
document.getElementById('startBtn').onclick = startGame;
|
|
|
|
// ============== OPTIONS ==============
|
|
function toggleOptions() {
|
|
document.getElementById('optionsPanel').classList.toggle('open');
|
|
}
|
|
document.getElementById('optHP').oninput = function() {
|
|
document.getElementById('optHPVal').textContent = this.value;
|
|
};
|
|
document.getElementById('optAmmo').oninput = function() {
|
|
document.getElementById('optAmmoVal').textContent = this.value;
|
|
};
|
|
document.getElementById('optBulletSize').oninput = function() {
|
|
document.getElementById('optBulletSizeVal').textContent = this.value;
|
|
};
|
|
document.getElementById('optAmmoSpawn').oninput = function() {
|
|
document.getElementById('optAmmoSpawnVal').textContent = this.value;
|
|
};
|
|
|
|
// ============== RESIZE ==============
|
|
function resize() {
|
|
W = CANVAS.width = window.innerWidth;
|
|
H = CANVAS.height = window.innerHeight;
|
|
buildPlatforms();
|
|
}
|
|
window.addEventListener('resize', resize);
|
|
|
|
// ============== MAPS ==============
|
|
let selectedMap = 0;
|
|
|
|
// ============== MAP THEMES ==============
|
|
const MAP_THEMES = {
|
|
classique: {
|
|
// Background
|
|
skyTop: '#050520',
|
|
skyMid: '#0f0c29',
|
|
skyBot: '#1a1a3e',
|
|
stars: true,
|
|
moon: true,
|
|
bgImage: 'img/backgrounds/classic.png',
|
|
// Ground
|
|
groundTop: '#3a3a5a',
|
|
groundFill: '#2a2a4a',
|
|
groundDetail: '#4a4a6a',
|
|
// Platforms
|
|
platTop: '#8888bb',
|
|
platFill: '#50507a',
|
|
platBot: '#35355a',
|
|
platHighlight: '#aaaadd',
|
|
platStyle: 'metal', // metal, stone, sand
|
|
},
|
|
tours: {
|
|
skyTop: '#0a0015',
|
|
skyMid: '#1a0a2e',
|
|
skyBot: '#2d1b4e',
|
|
stars: true,
|
|
moon: false,
|
|
bgImage: 'img/backgrounds/medieval.png',
|
|
groundTop: '#4a3a2a',
|
|
groundFill: '#3a2a1a',
|
|
groundDetail: '#5a4a3a',
|
|
platTop: '#8a7a6a',
|
|
platFill: '#6a5a4a',
|
|
platBot: '#4a3a2a',
|
|
platHighlight: '#aa9a8a',
|
|
platStyle: 'stone',
|
|
},
|
|
pyramide: {
|
|
skyTop: '#1a0800',
|
|
skyMid: '#3d1f00',
|
|
skyBot: '#6b3a11',
|
|
stars: false,
|
|
moon: false,
|
|
bgImage: 'img/backgrounds/pyramide.png',
|
|
groundTop: '#c2a24d',
|
|
groundFill: '#a08030',
|
|
groundDetail: '#d4b85a',
|
|
platTop: '#d4b060',
|
|
platFill: '#b89040',
|
|
platBot: '#8a6a20',
|
|
platHighlight: '#e8cc80',
|
|
platStyle: 'sand',
|
|
},
|
|
};
|
|
|
|
const MAPS = [
|
|
{
|
|
name: 'Classique',
|
|
theme: 'classique',
|
|
build: (W, H) => [
|
|
{ x: 0, y: H - 30, w: W, h: 30, solid: true },
|
|
{ x: W * 0.1, y: H * 0.75, w: W * 0.2, h: 16 },
|
|
{ x: W * 0.7, y: H * 0.75, w: W * 0.2, h: 16 },
|
|
{ x: W * 0.35, y: H * 0.6, w: W * 0.3, h: 16 },
|
|
{ x: W * 0.05, y: H * 0.45, w: W * 0.18, h: 16 },
|
|
{ x: W * 0.77, y: H * 0.45, w: W * 0.18, h: 16 },
|
|
{ x: W * 0.3, y: H * 0.35, w: W * 0.15, h: 16 },
|
|
{ x: W * 0.55, y: H * 0.35, w: W * 0.15, h: 16 },
|
|
{ x: W * 0.4, y: H * 0.18, w: W * 0.2, h: 16 },
|
|
{ x: W * 0.0, y: H * 0.6, w: W * 0.07, h: 16 },
|
|
{ x: W * 0.93, y: H * 0.6, w: W * 0.07, h: 16 },
|
|
]
|
|
},
|
|
{
|
|
name: 'Tours',
|
|
theme: 'tours',
|
|
build: (W, H) => [
|
|
{ x: 0, y: H - 30, w: W, h: 30, solid: true },
|
|
{ x: W * 0.05, y: H * 0.75, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.03, y: H * 0.55, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.05, y: H * 0.35, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.03, y: H * 0.18, w: W * 0.14, h: 16 },
|
|
{ x: W * 0.83, y: H * 0.75, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.85, y: H * 0.55, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.83, y: H * 0.35, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.84, y: H * 0.18, w: W * 0.14, h: 16 },
|
|
{ x: W * 0.25, y: H * 0.65, w: W * 0.5, h: 16 },
|
|
{ x: W * 0.3, y: H * 0.4, w: W * 0.4, h: 16 },
|
|
{ x: W * 0.35, y: H * 0.2, w: W * 0.3, h: 16 },
|
|
{ x: W * 0.45, y: H * 0.8, w: W * 0.1, h: 16 },
|
|
]
|
|
},
|
|
{
|
|
name: 'Pyramide',
|
|
theme: 'pyramide',
|
|
build: (W, H) => [
|
|
{ x: 0, y: H - 30, w: W, h: 30, solid: true },
|
|
{ x: W * 0.1, y: H * 0.78, w: W * 0.35, h: 16 },
|
|
{ x: W * 0.55, y: H * 0.78, w: W * 0.35, h: 16 },
|
|
{ x: W * 0.18, y: H * 0.6, w: W * 0.25, h: 16 },
|
|
{ x: W * 0.57, y: H * 0.6, w: W * 0.25, h: 16 },
|
|
{ x: W * 0.28, y: H * 0.43, w: W * 0.18, h: 16 },
|
|
{ x: W * 0.54, y: H * 0.43, w: W * 0.18, h: 16 },
|
|
{ x: W * 0.38, y: H * 0.25, w: W * 0.24, h: 16 },
|
|
{ x: W * 0.44, y: H * 0.12, w: W * 0.12, h: 16 },
|
|
{ x: W * 0.0, y: H * 0.5, w: W * 0.08, h: 16 },
|
|
{ x: W * 0.92, y: H * 0.5, w: W * 0.08, h: 16 },
|
|
]
|
|
},
|
|
];
|
|
|
|
function buildPlatforms() {
|
|
platforms = MAPS[selectedMap].build(W, H);
|
|
// Ensure all platforms have solid property (default false = passthrough)
|
|
platforms.forEach(p => { if (p.solid === undefined) p.solid = false; });
|
|
}
|
|
|
|
function buildMapSelect() {
|
|
const container = document.getElementById('mapSelect');
|
|
container.innerHTML = '';
|
|
MAPS.forEach((map, i) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'map-card' + (i === 0 ? ' selected' : '');
|
|
|
|
// Mini preview canvas
|
|
const preview = document.createElement('canvas');
|
|
preview.width = 160;
|
|
preview.height = 90;
|
|
const pctx = preview.getContext('2d');
|
|
|
|
// Draw mini preview with theme
|
|
const pw = 160, ph = 90;
|
|
const theme = MAP_THEMES[map.theme];
|
|
const grad = pctx.createLinearGradient(0, 0, 0, ph);
|
|
grad.addColorStop(0, theme.skyTop);
|
|
grad.addColorStop(0.5, theme.skyMid);
|
|
grad.addColorStop(1, theme.skyBot);
|
|
pctx.fillStyle = grad;
|
|
pctx.fillRect(0, 0, pw, ph);
|
|
|
|
const plats = map.build(pw, ph);
|
|
plats.forEach(p => {
|
|
pctx.fillStyle = p.solid ? theme.groundTop : theme.platFill;
|
|
pctx.fillRect(p.x, p.y, p.w, Math.max(p.h * (ph / 600), 2));
|
|
});
|
|
|
|
const nameDiv = document.createElement('div');
|
|
nameDiv.className = 'name';
|
|
nameDiv.textContent = map.name;
|
|
|
|
card.appendChild(preview);
|
|
card.appendChild(nameDiv);
|
|
card.onclick = () => {
|
|
document.querySelectorAll('.map-card').forEach(el => el.classList.remove('selected'));
|
|
card.classList.add('selected');
|
|
selectedMap = i;
|
|
};
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
// ============== SPAWN POINTS ==============
|
|
function getSpawnPoints() {
|
|
const base = [
|
|
{ x: W * 0.15, y: H * 0.7 },
|
|
{ x: W * 0.85, y: H * 0.7 },
|
|
{ x: W * 0.5, y: H * 0.15 },
|
|
{ x: W * 0.5, y: H * 0.55 },
|
|
{ x: W * 0.2, y: H * 0.4 },
|
|
{ x: W * 0.8, y: H * 0.4 },
|
|
{ x: W * 0.35, y: H * 0.3 },
|
|
{ x: W * 0.65, y: H * 0.3 },
|
|
];
|
|
// Return enough spawn points for all characters
|
|
while (base.length < CHARACTERS.length) {
|
|
base.push({ x: W * (0.1 + Math.random() * 0.8), y: H * 0.7 });
|
|
}
|
|
return base;
|
|
}
|
|
|
|
// ============== PLAYER CLASS ==============
|
|
class Player {
|
|
constructor(charIndex, spawnIndex, isBot) {
|
|
this.char = CHARACTERS[charIndex];
|
|
this.isBot = isBot;
|
|
this.spawnIndex = spawnIndex;
|
|
this.w = 56;
|
|
this.h = 84;
|
|
this.reset();
|
|
this.score = 0;
|
|
this.aimAngle = 0;
|
|
// animation
|
|
this.animTimer = 0;
|
|
this.legPhase = 0;
|
|
this.facingRight = true;
|
|
// bot AI
|
|
this.botTarget = null;
|
|
this.botShootTimer = 0;
|
|
this.botMoveDir = 0;
|
|
this.botJumpTimer = 0;
|
|
this.botDecisionTimer = 0;
|
|
}
|
|
|
|
reset() {
|
|
const sp = getSpawnPoints()[this.spawnIndex];
|
|
this.x = sp.x;
|
|
this.y = sp.y;
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
this.hp = MAX_HP;
|
|
this.ammo = MAX_AMMO;
|
|
this.alive = true;
|
|
this.onGround = false;
|
|
this.jumpsLeft = 2; // double jump
|
|
this.dropTimer = 0; // frames to ignore platforms (drop through)
|
|
this.respawnTimer = 0;
|
|
this.hitFlash = 0; // frames of red flash when hit
|
|
this.invincible = 60; // frames of invincibility after spawn
|
|
}
|
|
|
|
update() {
|
|
if (!this.alive) {
|
|
this.respawnTimer -= 16;
|
|
if (this.respawnTimer <= 0) {
|
|
this.reset();
|
|
this.invincible = 90;
|
|
}
|
|
return;
|
|
}
|
|
if (this.invincible > 0) this.invincible--;
|
|
if (this.hitFlash > 0) this.hitFlash--;
|
|
|
|
// Movement
|
|
this.x += this.vx;
|
|
this.vy += GRAVITY;
|
|
this.y += this.vy;
|
|
|
|
// Platform collision
|
|
this.onGround = false;
|
|
if (this.dropTimer > 0) this.dropTimer--;
|
|
for (const p of platforms) {
|
|
// Skip non-solid platforms when dropping through
|
|
if (!p.solid && this.dropTimer > 0) continue;
|
|
if (this.x + this.w > p.x && this.x < p.x + p.w &&
|
|
this.y + this.h > p.y && this.y + this.h < p.y + p.h + this.vy + 2 &&
|
|
this.vy >= 0) {
|
|
this.y = p.y - this.h;
|
|
this.vy = 0;
|
|
this.onGround = true;
|
|
this.jumpsLeft = 2;
|
|
}
|
|
}
|
|
|
|
// World bounds
|
|
if (this.x < 0) this.x = 0;
|
|
if (this.x + this.w > W) this.x = W - this.w;
|
|
if (this.y > H + 100) {
|
|
this.die(null);
|
|
}
|
|
|
|
// Animation
|
|
if (Math.abs(this.vx) > 0.5) {
|
|
this.animTimer += 0.15;
|
|
this.legPhase = Math.sin(this.animTimer * 5);
|
|
this.facingRight = this.vx > 0;
|
|
} else {
|
|
this.legPhase *= 0.8;
|
|
}
|
|
|
|
// Friction
|
|
if (!this.isBot) {
|
|
// handled by input
|
|
} else {
|
|
this.vx *= 0.85;
|
|
}
|
|
}
|
|
|
|
die(killer) {
|
|
this.alive = false;
|
|
this.respawnTimer = RESPAWN_TIME;
|
|
// Particles
|
|
for (let i = 0; i < 20; i++) {
|
|
particles.push({
|
|
x: this.x + this.w / 2,
|
|
y: this.y + this.h / 2,
|
|
vx: (Math.random() - 0.5) * 10,
|
|
vy: (Math.random() - 0.5) * 10,
|
|
life: 40 + Math.random() * 20,
|
|
color: this.char.color,
|
|
size: 3 + Math.random() * 4
|
|
});
|
|
}
|
|
if (killer && killer !== this) {
|
|
killer.score++;
|
|
if (killer.score >= WIN_SCORE) {
|
|
endGame(killer);
|
|
}
|
|
}
|
|
}
|
|
|
|
shoot() {
|
|
if (this.ammo <= 0 || !this.alive) return;
|
|
this.ammo--;
|
|
const cx = this.x + this.w / 2;
|
|
const cy = this.y + this.h * 0.35;
|
|
const cos = Math.cos(this.aimAngle);
|
|
const sin = Math.sin(this.aimAngle);
|
|
bullets.push({
|
|
x: cx + cos * 48,
|
|
y: cy + sin * 48,
|
|
vx: cos * BULLET_SPEED,
|
|
vy: sin * BULLET_SPEED,
|
|
owner: this,
|
|
life: 120
|
|
});
|
|
// Muzzle flash particles
|
|
for (let i = 0; i < 5; i++) {
|
|
particles.push({
|
|
x: cx + cos * 50,
|
|
y: cy + sin * 50,
|
|
vx: cos * (5 + Math.random() * 5) + (Math.random() - 0.5) * 3,
|
|
vy: sin * (5 + Math.random() * 5) + (Math.random() - 0.5) * 3,
|
|
life: 8 + Math.random() * 6,
|
|
color: '#f1c40f',
|
|
size: 2 + Math.random() * 3
|
|
});
|
|
}
|
|
}
|
|
|
|
draw() {
|
|
if (!this.alive) return;
|
|
const cx = this.x + this.w / 2;
|
|
const cy = this.y + this.h / 2;
|
|
|
|
// Invincibility blink
|
|
if (this.invincible > 0 && Math.floor(this.invincible / 4) % 2 === 0) {
|
|
CTX.globalAlpha = 0.4;
|
|
}
|
|
|
|
// Character outline glow for visibility
|
|
CTX.shadowColor = 'rgba(0,0,0,0.7)';
|
|
CTX.shadowBlur = 6;
|
|
|
|
// Hit flash red overlay
|
|
const isHit = this.hitFlash > 0;
|
|
const col = isHit ? '#ff2222' : this.char.color;
|
|
const colDark = isHit ? '#cc1111' : this.char.colorDark;
|
|
const skinColor = isHit ? '#ff8888' : '#e8b89a';
|
|
const skinShadow = isHit ? '#cc5555' : '#c99a7c';
|
|
|
|
const cos = Math.cos(this.aimAngle);
|
|
const sin = Math.sin(this.aimAngle);
|
|
const legBaseY = this.y + 62;
|
|
const legSpread = this.legPhase * 12;
|
|
const kneeOffset = Math.abs(this.legPhase) * 4;
|
|
CTX.lineCap = 'round';
|
|
CTX.lineJoin = 'round';
|
|
|
|
// === LEGS (behind body) ===
|
|
// Left leg - thigh
|
|
CTX.strokeStyle = '#2c3e50';
|
|
CTX.lineWidth = 9;
|
|
const lKneeX = cx - 8 - legSpread * 0.5;
|
|
const lKneeY = legBaseY + 10 + kneeOffset;
|
|
CTX.beginPath();
|
|
CTX.moveTo(cx - 6, legBaseY);
|
|
CTX.lineTo(lKneeX, lKneeY);
|
|
CTX.stroke();
|
|
// Left leg - shin
|
|
CTX.strokeStyle = '#34495e';
|
|
CTX.lineWidth = 8;
|
|
CTX.beginPath();
|
|
CTX.moveTo(lKneeX, lKneeY);
|
|
CTX.lineTo(cx - 8 - legSpread, legBaseY + 20);
|
|
CTX.stroke();
|
|
// Left shoe
|
|
CTX.fillStyle = isHit ? '#aa1111' : '#1a1a2e';
|
|
CTX.beginPath();
|
|
const lFootX = cx - 8 - legSpread;
|
|
CTX.ellipse(lFootX + (this.facingRight ? 3 : -3), legBaseY + 21, 7, 4, 0, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// Right leg - thigh
|
|
CTX.strokeStyle = '#2c3e50';
|
|
CTX.lineWidth = 9;
|
|
const rKneeX = cx + 8 + legSpread * 0.5;
|
|
const rKneeY = legBaseY + 10 + kneeOffset;
|
|
CTX.beginPath();
|
|
CTX.moveTo(cx + 6, legBaseY);
|
|
CTX.lineTo(rKneeX, rKneeY);
|
|
CTX.stroke();
|
|
// Right leg - shin
|
|
CTX.strokeStyle = '#34495e';
|
|
CTX.lineWidth = 8;
|
|
CTX.beginPath();
|
|
CTX.moveTo(rKneeX, rKneeY);
|
|
CTX.lineTo(cx + 8 + legSpread, legBaseY + 20);
|
|
CTX.stroke();
|
|
// Right shoe
|
|
CTX.fillStyle = isHit ? '#aa1111' : '#1a1a2e';
|
|
CTX.beginPath();
|
|
const rFootX = cx + 8 + legSpread;
|
|
CTX.ellipse(rFootX + (this.facingRight ? 3 : -3), legBaseY + 21, 7, 4, 0, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// === TORSO ===
|
|
// Main torso shape
|
|
CTX.fillStyle = col;
|
|
CTX.beginPath();
|
|
CTX.moveTo(cx - 16, this.y + 34);
|
|
CTX.lineTo(cx + 16, this.y + 34);
|
|
CTX.lineTo(cx + 14, this.y + 64);
|
|
CTX.lineTo(cx - 14, this.y + 64);
|
|
CTX.closePath();
|
|
CTX.fill();
|
|
// Torso shading (darker side)
|
|
CTX.fillStyle = colDark;
|
|
CTX.beginPath();
|
|
CTX.moveTo(cx, this.y + 34);
|
|
CTX.lineTo(cx + 16, this.y + 34);
|
|
CTX.lineTo(cx + 14, this.y + 64);
|
|
CTX.lineTo(cx, this.y + 64);
|
|
CTX.closePath();
|
|
CTX.fill();
|
|
// Belt
|
|
CTX.fillStyle = '#2c2c2c';
|
|
CTX.fillRect(cx - 14, this.y + 59, 28, 5);
|
|
CTX.fillStyle = '#c0a030';
|
|
CTX.fillRect(cx - 3, this.y + 59, 6, 5); // buckle
|
|
// Collar / neckline
|
|
CTX.fillStyle = colDark;
|
|
CTX.beginPath();
|
|
CTX.moveTo(cx - 8, this.y + 34);
|
|
CTX.lineTo(cx, this.y + 40);
|
|
CTX.lineTo(cx + 8, this.y + 34);
|
|
CTX.closePath();
|
|
CTX.fill();
|
|
|
|
// === NECK ===
|
|
CTX.fillStyle = skinColor;
|
|
CTX.fillRect(cx - 5, this.y + 26, 10, 10);
|
|
CTX.fillStyle = skinShadow;
|
|
CTX.fillRect(cx, this.y + 26, 5, 10);
|
|
|
|
// === SHOULDERS ===
|
|
CTX.fillStyle = col;
|
|
CTX.beginPath();
|
|
CTX.arc(cx - 16, this.y + 37, 6, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.beginPath();
|
|
CTX.arc(cx + 16, this.y + 37, 6, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// === OFF-HAND ARM (behind, not holding gun) ===
|
|
const offSide = cos > 0 ? -1 : 1;
|
|
const offArmX = cx + offSide * 16;
|
|
const offArmY = this.y + 40;
|
|
const offSwing = this.legPhase * 6 * offSide;
|
|
// Upper arm
|
|
CTX.strokeStyle = col;
|
|
CTX.lineWidth = 7;
|
|
CTX.beginPath();
|
|
CTX.moveTo(offArmX, offArmY);
|
|
CTX.lineTo(offArmX + offSwing, offArmY + 14);
|
|
CTX.stroke();
|
|
// Forearm
|
|
CTX.strokeStyle = skinColor;
|
|
CTX.lineWidth = 6;
|
|
CTX.beginPath();
|
|
CTX.moveTo(offArmX + offSwing, offArmY + 14);
|
|
CTX.lineTo(offArmX + offSwing * 1.3, offArmY + 24);
|
|
CTX.stroke();
|
|
// Hand
|
|
CTX.fillStyle = skinColor;
|
|
CTX.beginPath();
|
|
CTX.arc(offArmX + offSwing * 1.3, offArmY + 25, 4, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// === GUN ARM ===
|
|
const gunSide = cos > 0 ? 1 : -1;
|
|
const gunArmX = cx + gunSide * 16;
|
|
const gunArmY = this.y + 40;
|
|
const elbowX = gunArmX + cos * 14;
|
|
const elbowY = gunArmY + sin * 14;
|
|
// Upper arm (shirt color)
|
|
CTX.strokeStyle = col;
|
|
CTX.lineWidth = 7;
|
|
CTX.beginPath();
|
|
CTX.moveTo(gunArmX, gunArmY);
|
|
CTX.lineTo(elbowX, elbowY);
|
|
CTX.stroke();
|
|
// Forearm (skin)
|
|
const handX = elbowX + cos * 12;
|
|
const handY = elbowY + sin * 12;
|
|
CTX.strokeStyle = skinColor;
|
|
CTX.lineWidth = 6;
|
|
CTX.beginPath();
|
|
CTX.moveTo(elbowX, elbowY);
|
|
CTX.lineTo(handX, handY);
|
|
CTX.stroke();
|
|
// Hand gripping gun
|
|
CTX.fillStyle = skinColor;
|
|
CTX.beginPath();
|
|
CTX.arc(handX, handY, 4, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// === GUN ===
|
|
const gunStart = handX;
|
|
const gunStartY = handY;
|
|
const gunEndX = handX + cos * 18;
|
|
const gunEndY = handY + sin * 18;
|
|
// Gun body
|
|
CTX.strokeStyle = '#333';
|
|
CTX.lineWidth = 5;
|
|
CTX.beginPath();
|
|
CTX.moveTo(gunStart, gunStartY);
|
|
CTX.lineTo(gunEndX, gunEndY);
|
|
CTX.stroke();
|
|
// Gun barrel (lighter)
|
|
CTX.strokeStyle = '#555';
|
|
CTX.lineWidth = 3;
|
|
CTX.beginPath();
|
|
CTX.moveTo(gunEndX, gunEndY);
|
|
CTX.lineTo(gunEndX + cos * 8, gunEndY + sin * 8);
|
|
CTX.stroke();
|
|
// Gun handle
|
|
CTX.strokeStyle = '#2a2a2a';
|
|
CTX.lineWidth = 4;
|
|
CTX.beginPath();
|
|
CTX.moveTo(gunStart + cos * 2, gunStartY + sin * 2);
|
|
CTX.lineTo(gunStart + cos * 2 + Math.sin(this.aimAngle) * 7, gunStartY + sin * 2 - Math.cos(this.aimAngle) * 7);
|
|
CTX.stroke();
|
|
// Muzzle tip
|
|
CTX.fillStyle = '#222';
|
|
CTX.beginPath();
|
|
CTX.arc(gunEndX + cos * 8, gunEndY + sin * 8, 2, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// === HEAD ===
|
|
const headSize = 56;
|
|
CTX.save();
|
|
CTX.translate(cx, this.y + 16);
|
|
const flip = this.facingRight ? 1 : -1;
|
|
CTX.scale(flip, 1);
|
|
CTX.drawImage(this.char.img, -headSize / 2, -headSize / 2, headSize, headSize);
|
|
// Red overlay on head when hit
|
|
if (isHit) {
|
|
CTX.globalCompositeOperation = 'source-atop';
|
|
CTX.fillStyle = `rgba(255, 30, 30, ${0.5 * (this.hitFlash / 12)})`;
|
|
CTX.fillRect(-headSize / 2, -headSize / 2, headSize, headSize);
|
|
CTX.globalCompositeOperation = 'source-over';
|
|
}
|
|
CTX.restore();
|
|
|
|
CTX.globalAlpha = 1;
|
|
CTX.shadowColor = 'transparent';
|
|
CTX.shadowBlur = 0;
|
|
|
|
// Name tag with outline for readability
|
|
CTX.font = 'bold 13px sans-serif';
|
|
CTX.textAlign = 'center';
|
|
CTX.strokeStyle = 'rgba(0,0,0,0.7)';
|
|
CTX.lineWidth = 3;
|
|
CTX.strokeText(this.char.name, cx, this.y - 10);
|
|
CTX.fillStyle = '#fff';
|
|
CTX.fillText(this.char.name, cx, this.y - 10);
|
|
|
|
// HP bar
|
|
const hpW = 52;
|
|
CTX.fillStyle = 'rgba(0,0,0,0.5)';
|
|
CTX.fillRect(cx - hpW / 2, this.y - 7, hpW, 5);
|
|
const hpRatio = this.hp / MAX_HP;
|
|
const hpColor = hpRatio > 0.5 ? '#2ecc71' : hpRatio > 0.25 ? '#f39c12' : '#e74c3c';
|
|
CTX.fillStyle = hpColor;
|
|
CTX.fillRect(cx - hpW / 2, this.y - 7, hpW * hpRatio, 5);
|
|
}
|
|
|
|
// ============== BOT AI ==============
|
|
updateBot() {
|
|
if (!this.alive || !this.isBot) return;
|
|
|
|
this.botDecisionTimer--;
|
|
if (this.botDecisionTimer <= 0) {
|
|
this.botDecisionTimer = 15 + Math.random() * 20;
|
|
|
|
// Find closest alive enemy
|
|
let closest = null;
|
|
let closestDist = Infinity;
|
|
for (const p of players) {
|
|
if (p === this || !p.alive) continue;
|
|
const d = Math.hypot(p.x - this.x, p.y - this.y);
|
|
if (d < closestDist) {
|
|
closestDist = d;
|
|
closest = p;
|
|
}
|
|
}
|
|
this.botTarget = closest;
|
|
|
|
// Movement decision
|
|
if (closest) {
|
|
const dx = closest.x - this.x;
|
|
if (Math.abs(dx) > 80) {
|
|
this.botMoveDir = dx > 0 ? 1 : -1;
|
|
} else {
|
|
this.botMoveDir = Math.random() < 0.3 ? (Math.random() < 0.5 ? -1 : 1) : 0;
|
|
}
|
|
// Jump decision (double jump enabled)
|
|
if (closest.y < this.y - 40 && this.jumpsLeft > 0 && Math.random() < 0.5) {
|
|
this.botJumpTimer = 1;
|
|
}
|
|
if (this.jumpsLeft > 0 && !this.onGround && Math.random() < 0.08) {
|
|
this.botJumpTimer = 1;
|
|
}
|
|
} else {
|
|
this.botMoveDir = Math.random() < 0.5 ? -1 : 1;
|
|
}
|
|
}
|
|
|
|
// Apply movement
|
|
this.vx = this.botMoveDir * MOVE_SPEED * 0.85;
|
|
if (this.vx > 0) this.facingRight = true;
|
|
if (this.vx < 0) this.facingRight = false;
|
|
|
|
// Jump
|
|
if (this.botJumpTimer > 0 && this.jumpsLeft > 0) {
|
|
this.vy = JUMP_FORCE;
|
|
this.jumpsLeft--;
|
|
this.botJumpTimer = 0;
|
|
}
|
|
// Random jump to avoid getting stuck
|
|
if (this.jumpsLeft > 0 && Math.random() < 0.02) {
|
|
this.vy = JUMP_FORCE;
|
|
this.jumpsLeft--;
|
|
}
|
|
// Drop through platform if target is below
|
|
if (this.botTarget && this.botTarget.y > this.y + 60 && this.onGround && Math.random() < 0.06) {
|
|
this.dropTimer = 10;
|
|
this.y += 3;
|
|
}
|
|
|
|
// Aim and shoot
|
|
if (this.botTarget && this.botTarget.alive) {
|
|
const tx = this.botTarget.x + this.botTarget.w / 2;
|
|
const ty = this.botTarget.y + this.botTarget.h / 2;
|
|
const cx = this.x + this.w / 2;
|
|
const cy = this.y + this.h * 0.35;
|
|
this.aimAngle = Math.atan2(ty - cy, tx - cx);
|
|
|
|
this.botShootTimer--;
|
|
const dist = Math.hypot(tx - cx, ty - cy);
|
|
if (this.botShootTimer <= 0 && this.ammo > 0 && dist < 500) {
|
|
// Add some inaccuracy
|
|
this.aimAngle += (Math.random() - 0.5) * 0.25;
|
|
this.shoot();
|
|
this.botShootTimer = 15 + Math.random() * 25;
|
|
}
|
|
}
|
|
|
|
// Pick up ammo - go to nearest if low
|
|
if (this.ammo <= 2 && ammoPickups.length > 0) {
|
|
let nearestAmmo = null;
|
|
let nearestDist = Infinity;
|
|
for (const a of ammoPickups) {
|
|
const d = Math.hypot(a.x - this.x, a.y - this.y);
|
|
if (d < nearestDist) {
|
|
nearestDist = d;
|
|
nearestAmmo = a;
|
|
}
|
|
}
|
|
if (nearestAmmo) {
|
|
this.botMoveDir = nearestAmmo.x > this.x + this.w / 2 ? 1 : -1;
|
|
if (nearestAmmo.y < this.y - 30 && this.jumpsLeft > 0) {
|
|
this.vy = JUMP_FORCE;
|
|
this.jumpsLeft--;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============== AMMO PICKUP ==============
|
|
function spawnAmmoPickup() {
|
|
const plat = platforms[Math.floor(Math.random() * (platforms.length - 1)) + 0];
|
|
ammoPickups.push({
|
|
x: plat.x + Math.random() * (plat.w - 20) + 10,
|
|
y: plat.y - 28,
|
|
bobPhase: Math.random() * Math.PI * 2
|
|
});
|
|
}
|
|
|
|
// ============== INPUT ==============
|
|
window.addEventListener('keydown', e => {
|
|
keys[e.key.toLowerCase()] = true;
|
|
if (e.key === ' ') e.preventDefault();
|
|
});
|
|
window.addEventListener('keyup', e => {
|
|
keys[e.key.toLowerCase()] = false;
|
|
});
|
|
window.addEventListener('mousemove', e => {
|
|
mouseX = e.clientX;
|
|
mouseY = e.clientY;
|
|
});
|
|
window.addEventListener('mousedown', e => {
|
|
if (e.button === 0 && gameRunning) {
|
|
const p = players[0];
|
|
if (p && p.alive) p.shoot();
|
|
}
|
|
});
|
|
|
|
function handleInput() {
|
|
const p = players[0];
|
|
if (!p || !p.alive) return;
|
|
|
|
p.vx = 0;
|
|
if (keys['q'] || keys['a']) { p.vx = -MOVE_SPEED; p.facingRight = false; }
|
|
if (keys['d']) { p.vx = MOVE_SPEED; p.facingRight = true; }
|
|
if ((keys[' '] || keys['z'] || keys['w']) && p.jumpsLeft > 0 && !p._jumpHeld) {
|
|
p.vy = JUMP_FORCE;
|
|
p.jumpsLeft--;
|
|
p._jumpHeld = true;
|
|
// Double jump particle effect
|
|
if (p.jumpsLeft === 0) {
|
|
for (let i = 0; i < 6; i++) {
|
|
particles.push({
|
|
x: p.x + p.w / 2 + (Math.random() - 0.5) * 16,
|
|
y: p.y + p.h,
|
|
vx: (Math.random() - 0.5) * 3,
|
|
vy: Math.random() * 3 + 1,
|
|
life: 15 + Math.random() * 10,
|
|
color: p.char.color,
|
|
size: 3
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (!keys[' '] && !keys['z'] && !keys['w']) {
|
|
p._jumpHeld = false;
|
|
}
|
|
|
|
// Drop through platform (arrow down or S)
|
|
if ((keys['arrowdown'] || keys['s']) && p.onGround && !p._dropHeld) {
|
|
p.dropTimer = 10;
|
|
p.y += 3; // nudge down to pass through
|
|
p._dropHeld = true;
|
|
}
|
|
if (!keys['arrowdown'] && !keys['s']) {
|
|
p._dropHeld = false;
|
|
}
|
|
|
|
// Aim at mouse
|
|
const cx = p.x + p.w / 2;
|
|
const cy = p.y + p.h * 0.35;
|
|
p.aimAngle = Math.atan2(mouseY - cy, mouseX - cx);
|
|
if (Math.cos(p.aimAngle) < 0) p.facingRight = false;
|
|
if (Math.cos(p.aimAngle) > 0) p.facingRight = true;
|
|
}
|
|
|
|
// ============== GAME START ==============
|
|
function startGame() {
|
|
// Apply options
|
|
MAX_HP = parseInt(document.getElementById('optHP').value);
|
|
MAX_AMMO = parseInt(document.getElementById('optAmmo').value);
|
|
BULLET_SIZE = parseInt(document.getElementById('optBulletSize').value);
|
|
BULLET_BOUNCE = document.getElementById('optBounce').checked;
|
|
AMMO_SPAWN_INTERVAL = parseInt(document.getElementById('optAmmoSpawn').value) * 1000;
|
|
|
|
// Preload background image if set
|
|
const theme = MAP_THEMES[MAPS[selectedMap].theme];
|
|
if (theme.bgImage) loadBgImage(theme.bgImage);
|
|
|
|
resize();
|
|
document.getElementById('menu').style.display = 'none';
|
|
document.getElementById('hud').style.display = 'flex';
|
|
|
|
players = [];
|
|
bullets = [];
|
|
ammoPickups = [];
|
|
particles = [];
|
|
|
|
const botCount = parseInt(document.getElementById('botCount').value);
|
|
const availableChars = CHARACTERS.map((_, i) => i);
|
|
|
|
// Human player
|
|
players.push(new Player(selectedChar, 0, false));
|
|
availableChars.splice(availableChars.indexOf(selectedChar), 1);
|
|
|
|
// Bots
|
|
for (let i = 0; i < botCount; i++) {
|
|
const charIdx = availableChars.length > 0 ? availableChars.shift() : i % CHARACTERS.length;
|
|
players.push(new Player(charIdx, i + 1, true));
|
|
}
|
|
|
|
gameRunning = true;
|
|
lastAmmoSpawn = Date.now();
|
|
|
|
// Initial ammo pickups
|
|
for (let i = 0; i < 3; i++) spawnAmmoPickup();
|
|
|
|
updateHUD();
|
|
gameLoop();
|
|
}
|
|
|
|
// ============== HUD ==============
|
|
function updateHUD() {
|
|
const hud = document.getElementById('hud');
|
|
hud.innerHTML = '';
|
|
for (const p of players) {
|
|
const div = document.createElement('div');
|
|
div.className = 'player-hud';
|
|
let bulletsHtml = '';
|
|
for (let i = 0; i < MAX_AMMO; i++) {
|
|
bulletsHtml += `<div class="bullet ${i < p.ammo ? '' : 'empty'}"></div>`;
|
|
}
|
|
div.innerHTML = `
|
|
<div class="hud-name" style="color:${p.char.color}">${p.char.name}</div>
|
|
<div class="hud-hp"><div class="hud-hp-bar" style="width:${p.hp}%;background:${p.hp > 50 ? '#2ecc71' : p.hp > 25 ? '#f39c12' : '#e74c3c'}"></div></div>
|
|
<div class="hud-ammo">${bulletsHtml}</div>
|
|
<div class="hud-score">${p.score}</div>
|
|
`;
|
|
hud.appendChild(div);
|
|
}
|
|
}
|
|
|
|
// ============== DRAW ==============
|
|
// Background image cache
|
|
let bgImages = {};
|
|
function loadBgImage(src) {
|
|
if (!src || bgImages[src]) return;
|
|
const img = new Image();
|
|
img.src = src;
|
|
bgImages[src] = img;
|
|
}
|
|
|
|
function drawBackground() {
|
|
const theme = MAP_THEMES[MAPS[selectedMap].theme];
|
|
|
|
// Custom background image
|
|
if (theme.bgImage && bgImages[theme.bgImage] && bgImages[theme.bgImage].complete) {
|
|
CTX.drawImage(bgImages[theme.bgImage], 0, 0, W, H);
|
|
// Slight overlay for readability
|
|
CTX.fillStyle = 'rgba(0,0,0,0.3)';
|
|
CTX.fillRect(0, 0, W, H);
|
|
return;
|
|
}
|
|
|
|
// Sky gradient
|
|
const grad = CTX.createLinearGradient(0, 0, 0, H);
|
|
grad.addColorStop(0, theme.skyTop);
|
|
grad.addColorStop(0.5, theme.skyMid);
|
|
grad.addColorStop(1, theme.skyBot);
|
|
CTX.fillStyle = grad;
|
|
CTX.fillRect(0, 0, W, H);
|
|
|
|
// Stars (night maps)
|
|
if (theme.stars) {
|
|
for (let i = 0; i < 80; i++) {
|
|
const sx = (i * 137.5 + 50) % W;
|
|
const sy = (i * 97.3 + 30) % (H * 0.5);
|
|
const ss = 0.8 + (i % 3) * 0.8;
|
|
const twinkle = 0.2 + 0.3 * Math.sin(animFrame * 0.02 + i);
|
|
CTX.fillStyle = `rgba(255,255,255,${twinkle})`;
|
|
CTX.beginPath();
|
|
CTX.arc(sx, sy, ss, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
}
|
|
}
|
|
|
|
// Moon (classique)
|
|
if (theme.moon) {
|
|
const mx = W * 0.82, my = H * 0.12, mr = 35;
|
|
const moonGrad = CTX.createRadialGradient(mx, my, 0, mx, my, mr * 1.5);
|
|
moonGrad.addColorStop(0, 'rgba(255,255,220,0.15)');
|
|
moonGrad.addColorStop(1, 'rgba(255,255,220,0)');
|
|
CTX.fillStyle = moonGrad;
|
|
CTX.beginPath();
|
|
CTX.arc(mx, my, mr * 3, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.fillStyle = '#e8e0c8';
|
|
CTX.beginPath();
|
|
CTX.arc(mx, my, mr, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.fillStyle = '#d0c8a8';
|
|
CTX.beginPath();
|
|
CTX.arc(mx - 8, my - 5, 8, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.beginPath();
|
|
CTX.arc(mx + 12, my + 8, 5, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.beginPath();
|
|
CTX.arc(mx + 3, my + 15, 4, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
}
|
|
|
|
// Pyramide map: sun + heat haze
|
|
if (MAPS[selectedMap].theme === 'pyramide') {
|
|
// Sun
|
|
const sunX = W * 0.5, sunY = H * 0.08, sunR = 45;
|
|
const sunGrad = CTX.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 4);
|
|
sunGrad.addColorStop(0, 'rgba(255,200,50,0.25)');
|
|
sunGrad.addColorStop(0.3, 'rgba(255,150,30,0.08)');
|
|
sunGrad.addColorStop(1, 'rgba(255,100,0,0)');
|
|
CTX.fillStyle = sunGrad;
|
|
CTX.beginPath();
|
|
CTX.arc(sunX, sunY, sunR * 4, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.fillStyle = '#ffe080';
|
|
CTX.beginPath();
|
|
CTX.arc(sunX, sunY, sunR, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
CTX.fillStyle = '#fff4cc';
|
|
CTX.beginPath();
|
|
CTX.arc(sunX, sunY, sunR * 0.6, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
// Dunes in background
|
|
CTX.fillStyle = 'rgba(160,110,40,0.3)';
|
|
CTX.beginPath();
|
|
CTX.moveTo(0, H * 0.85);
|
|
for (let x = 0; x <= W; x += 80) {
|
|
CTX.lineTo(x, H * 0.85 - Math.sin(x * 0.005 + 1) * 30 - Math.sin(x * 0.012) * 15);
|
|
}
|
|
CTX.lineTo(W, H); CTX.lineTo(0, H);
|
|
CTX.closePath();
|
|
CTX.fill();
|
|
}
|
|
|
|
// Tours map: castle silhouettes
|
|
if (MAPS[selectedMap].theme === 'tours') {
|
|
CTX.fillStyle = 'rgba(20,10,40,0.4)';
|
|
// Left castle silhouette
|
|
CTX.fillRect(W * 0.02, H * 0.3, 30, H * 0.7);
|
|
CTX.fillRect(W * 0.02 - 8, H * 0.3, 46, 15);
|
|
// Merlons
|
|
for (let i = 0; i < 4; i++) {
|
|
CTX.fillRect(W * 0.02 - 8 + i * 14, H * 0.3 - 10, 8, 10);
|
|
}
|
|
// Right castle silhouette
|
|
CTX.fillRect(W * 0.95, H * 0.35, 30, H * 0.65);
|
|
CTX.fillRect(W * 0.95 - 8, H * 0.35, 46, 15);
|
|
for (let i = 0; i < 4; i++) {
|
|
CTX.fillRect(W * 0.95 - 8 + i * 14, H * 0.35 - 10, 8, 10);
|
|
}
|
|
// Fog at bottom
|
|
const fogGrad = CTX.createLinearGradient(0, H * 0.85, 0, H);
|
|
fogGrad.addColorStop(0, 'rgba(30,15,50,0)');
|
|
fogGrad.addColorStop(1, 'rgba(30,15,50,0.4)');
|
|
CTX.fillStyle = fogGrad;
|
|
CTX.fillRect(0, H * 0.85, W, H * 0.15);
|
|
}
|
|
}
|
|
|
|
function drawPlatforms() {
|
|
const theme = MAP_THEMES[MAPS[selectedMap].theme];
|
|
|
|
for (const p of platforms) {
|
|
if (p.h > 20) {
|
|
// === GROUND ===
|
|
const gGrad = CTX.createLinearGradient(p.x, p.y, p.x, p.y + p.h);
|
|
gGrad.addColorStop(0, theme.groundTop);
|
|
gGrad.addColorStop(1, theme.groundFill);
|
|
CTX.fillStyle = gGrad;
|
|
CTX.fillRect(p.x, p.y, p.w, p.h);
|
|
// Top grass/edge line
|
|
CTX.fillStyle = theme.groundDetail;
|
|
CTX.fillRect(p.x, p.y, p.w, 4);
|
|
// Ground texture details
|
|
if (theme.platStyle === 'sand') {
|
|
CTX.fillStyle = 'rgba(0,0,0,0.08)';
|
|
for (let gx = p.x; gx < p.x + p.w; gx += 20) {
|
|
CTX.fillRect(gx, p.y + 6 + (gx % 7), 12, 2);
|
|
}
|
|
} else if (theme.platStyle === 'stone') {
|
|
CTX.strokeStyle = 'rgba(0,0,0,0.15)';
|
|
CTX.lineWidth = 1;
|
|
for (let gx = p.x + 20; gx < p.x + p.w; gx += 35) {
|
|
CTX.beginPath();
|
|
CTX.moveTo(gx, p.y + 5);
|
|
CTX.lineTo(gx, p.y + p.h);
|
|
CTX.stroke();
|
|
}
|
|
}
|
|
} else {
|
|
// === PLATFORM ===
|
|
const pGrad = CTX.createLinearGradient(p.x, p.y, p.x, p.y + p.h);
|
|
pGrad.addColorStop(0, theme.platTop);
|
|
pGrad.addColorStop(0.5, theme.platFill);
|
|
pGrad.addColorStop(1, theme.platBot);
|
|
CTX.fillStyle = pGrad;
|
|
|
|
if (theme.platStyle === 'metal') {
|
|
// Metal platform with rivets
|
|
CTX.beginPath();
|
|
CTX.roundRect(p.x, p.y, p.w, p.h, 3);
|
|
CTX.fill();
|
|
// Top shine
|
|
CTX.fillStyle = theme.platHighlight;
|
|
CTX.fillRect(p.x + 3, p.y + 1, p.w - 6, 2);
|
|
// Bottom shadow
|
|
CTX.fillStyle = 'rgba(0,0,0,0.3)';
|
|
CTX.fillRect(p.x + 2, p.y + p.h - 2, p.w - 4, 2);
|
|
// Rivets
|
|
CTX.fillStyle = 'rgba(255,255,255,0.25)';
|
|
const rivetSpacing = 30;
|
|
for (let rx = p.x + 12; rx < p.x + p.w - 8; rx += rivetSpacing) {
|
|
CTX.beginPath();
|
|
CTX.arc(rx, p.y + p.h / 2, 2, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
}
|
|
// Edge caps
|
|
CTX.fillStyle = theme.platTop;
|
|
CTX.beginPath();
|
|
CTX.roundRect(p.x, p.y, 6, p.h, [3, 0, 0, 3]);
|
|
CTX.fill();
|
|
CTX.beginPath();
|
|
CTX.roundRect(p.x + p.w - 6, p.y, 6, p.h, [0, 3, 3, 0]);
|
|
CTX.fill();
|
|
|
|
} else if (theme.platStyle === 'stone') {
|
|
// Stone/brick platform
|
|
CTX.beginPath();
|
|
CTX.roundRect(p.x, p.y, p.w, p.h, 2);
|
|
CTX.fill();
|
|
// Brick lines
|
|
CTX.strokeStyle = 'rgba(0,0,0,0.25)';
|
|
CTX.lineWidth = 1;
|
|
const brickW = 22;
|
|
let row = 0;
|
|
for (let by = p.y; by < p.y + p.h; by += 8) {
|
|
const offset = (row % 2) * (brickW / 2);
|
|
for (let bx = p.x + offset; bx < p.x + p.w; bx += brickW) {
|
|
CTX.strokeRect(bx, by, brickW, 8);
|
|
}
|
|
row++;
|
|
}
|
|
// Top moss/highlight
|
|
CTX.fillStyle = theme.platHighlight;
|
|
CTX.fillRect(p.x + 2, p.y, p.w - 4, 2);
|
|
// Hanging moss details
|
|
CTX.fillStyle = 'rgba(60,90,60,0.3)';
|
|
for (let mx = p.x + 15; mx < p.x + p.w - 10; mx += 40 + (mx % 17)) {
|
|
CTX.fillRect(mx, p.y + p.h, 3, 4 + (mx % 5));
|
|
}
|
|
|
|
} else if (theme.platStyle === 'sand') {
|
|
// Sandstone platform
|
|
CTX.beginPath();
|
|
CTX.roundRect(p.x, p.y, p.w, p.h, 4);
|
|
CTX.fill();
|
|
// Sandstone layers
|
|
CTX.fillStyle = 'rgba(0,0,0,0.06)';
|
|
CTX.fillRect(p.x + 3, p.y + 5, p.w - 6, 2);
|
|
CTX.fillRect(p.x + 3, p.y + 10, p.w - 6, 2);
|
|
// Top highlight (sun-lit)
|
|
CTX.fillStyle = theme.platHighlight;
|
|
CTX.fillRect(p.x + 4, p.y + 1, p.w - 8, 2);
|
|
// Cracks
|
|
CTX.strokeStyle = 'rgba(0,0,0,0.12)';
|
|
CTX.lineWidth = 1;
|
|
for (let cx = p.x + 25; cx < p.x + p.w - 20; cx += 50 + (cx % 23)) {
|
|
CTX.beginPath();
|
|
CTX.moveTo(cx, p.y + 3);
|
|
CTX.lineTo(cx + 5, p.y + p.h - 3);
|
|
CTX.stroke();
|
|
}
|
|
// Sand particles falling
|
|
CTX.fillStyle = 'rgba(200,170,80,0.2)';
|
|
for (let sx = p.x + 10; sx < p.x + p.w - 10; sx += 35 + (sx % 11)) {
|
|
const sy = p.y + p.h + (animFrame * 0.5 + sx) % 8;
|
|
CTX.fillRect(sx, sy, 2, 2);
|
|
}
|
|
}
|
|
|
|
// Drop-through indicator (small arrows if not solid)
|
|
if (!p.solid) {
|
|
CTX.fillStyle = 'rgba(255,255,255,0.08)';
|
|
const midX = p.x + p.w / 2;
|
|
CTX.beginPath();
|
|
CTX.moveTo(midX - 4, p.y + p.h + 2);
|
|
CTX.lineTo(midX + 4, p.y + p.h + 2);
|
|
CTX.lineTo(midX, p.y + p.h + 6);
|
|
CTX.closePath();
|
|
CTX.fill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawBullets() {
|
|
for (const b of bullets) {
|
|
// Outer glow halo
|
|
CTX.shadowColor = '#ff4444';
|
|
CTX.shadowBlur = 12;
|
|
|
|
// Trail
|
|
CTX.strokeStyle = 'rgba(255,80,30,0.6)';
|
|
CTX.lineWidth = BULLET_SIZE * 0.8;
|
|
CTX.lineCap = 'round';
|
|
CTX.beginPath();
|
|
CTX.moveTo(b.x, b.y);
|
|
CTX.lineTo(b.x - b.vx * 2, b.y - b.vy * 2);
|
|
CTX.stroke();
|
|
|
|
// Outer glow
|
|
const glowGrad = CTX.createRadialGradient(b.x, b.y, 0, b.x, b.y, BULLET_SIZE * 3);
|
|
glowGrad.addColorStop(0, 'rgba(255,100,30,0.4)');
|
|
glowGrad.addColorStop(0.5, 'rgba(255,60,20,0.15)');
|
|
glowGrad.addColorStop(1, 'rgba(255,30,10,0)');
|
|
CTX.fillStyle = glowGrad;
|
|
CTX.beginPath();
|
|
CTX.arc(b.x, b.y, BULLET_SIZE * 3, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// Bullet core
|
|
CTX.fillStyle = '#fff';
|
|
CTX.beginPath();
|
|
CTX.arc(b.x, b.y, BULLET_SIZE * 0.5, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// Bullet body
|
|
CTX.fillStyle = '#ff6030';
|
|
CTX.beginPath();
|
|
CTX.arc(b.x, b.y, BULLET_SIZE, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// Dark outline
|
|
CTX.strokeStyle = 'rgba(0,0,0,0.5)';
|
|
CTX.lineWidth = 1;
|
|
CTX.beginPath();
|
|
CTX.arc(b.x, b.y, BULLET_SIZE + 0.5, 0, Math.PI * 2);
|
|
CTX.stroke();
|
|
|
|
CTX.shadowColor = 'transparent';
|
|
CTX.shadowBlur = 0;
|
|
}
|
|
}
|
|
|
|
function drawAmmoPickups() {
|
|
for (const a of ammoPickups) {
|
|
a.bobPhase += 0.05;
|
|
const by = a.y + Math.sin(a.bobPhase) * 4;
|
|
const pulse = 0.7 + 0.3 * Math.sin(a.bobPhase * 2);
|
|
|
|
// Outer halo (pulsing)
|
|
const haloGrad = CTX.createRadialGradient(a.x, by, 5, a.x, by, 45);
|
|
haloGrad.addColorStop(0, `rgba(100,200,255,${0.25 * pulse})`);
|
|
haloGrad.addColorStop(0.5, `rgba(80,160,255,${0.1 * pulse})`);
|
|
haloGrad.addColorStop(1, 'rgba(80,160,255,0)');
|
|
CTX.fillStyle = haloGrad;
|
|
CTX.beginPath();
|
|
CTX.arc(a.x, by, 45, 0, Math.PI * 2);
|
|
CTX.fill();
|
|
|
|
// Inner glow
|
|
CTX.shadowColor = '#4fc3f7';
|
|
CTX.shadowBlur = 18;
|
|
|
|
// Box body
|
|
CTX.fillStyle = '#2e7d32';
|
|
CTX.beginPath();
|
|
CTX.roundRect(a.x - 16, by - 12, 32, 24, 3);
|
|
CTX.fill();
|
|
// Box bottom shade
|
|
CTX.fillStyle = '#1b5e20';
|
|
CTX.fillRect(a.x - 15, by, 30, 11);
|
|
// Box highlight top
|
|
CTX.fillStyle = 'rgba(255,255,255,0.2)';
|
|
CTX.fillRect(a.x - 14, by - 11, 28, 3);
|
|
// Bullet icon
|
|
CTX.fillStyle = '#fdd835';
|
|
CTX.fillRect(a.x - 3, by - 7, 6, 14);
|
|
CTX.fillStyle = '#ffee58';
|
|
CTX.fillRect(a.x - 1, by - 6, 2, 12);
|
|
// Border
|
|
CTX.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
CTX.lineWidth = 1;
|
|
CTX.beginPath();
|
|
CTX.roundRect(a.x - 16, by - 12, 32, 24, 3);
|
|
CTX.stroke();
|
|
|
|
CTX.shadowColor = 'transparent';
|
|
CTX.shadowBlur = 0;
|
|
}
|
|
}
|
|
|
|
function drawParticles() {
|
|
for (const p of particles) {
|
|
CTX.globalAlpha = p.life / 40;
|
|
CTX.fillStyle = p.color;
|
|
CTX.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
|
|
}
|
|
CTX.globalAlpha = 1;
|
|
}
|
|
|
|
function drawCrosshair() {
|
|
if (!players[0] || !players[0].alive) return;
|
|
CTX.strokeStyle = 'rgba(255,255,255,0.7)';
|
|
CTX.lineWidth = 1.5;
|
|
const s = 10;
|
|
CTX.beginPath();
|
|
CTX.moveTo(mouseX - s, mouseY); CTX.lineTo(mouseX + s, mouseY);
|
|
CTX.moveTo(mouseX, mouseY - s); CTX.lineTo(mouseX, mouseY + s);
|
|
CTX.stroke();
|
|
CTX.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
CTX.beginPath();
|
|
CTX.arc(mouseX, mouseY, 15, 0, Math.PI * 2);
|
|
CTX.stroke();
|
|
}
|
|
|
|
// ============== UPDATE ==============
|
|
function update() {
|
|
animFrame++;
|
|
|
|
// Human input
|
|
handleInput();
|
|
|
|
// Update players
|
|
for (const p of players) {
|
|
if (p.isBot) p.updateBot();
|
|
p.update();
|
|
}
|
|
|
|
// Update bullets
|
|
for (let i = bullets.length - 1; i >= 0; i--) {
|
|
const b = bullets[i];
|
|
b.x += b.vx;
|
|
b.y += b.vy;
|
|
b.vy += 0.05; // slight gravity on bullets
|
|
b.life--;
|
|
|
|
// Remove if off screen or expired
|
|
if (b.life <= 0 || b.y < -50 || b.y > H + 50) {
|
|
bullets.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Bounce off screen edges (left/right walls)
|
|
if (BULLET_BOUNCE) {
|
|
if (b.x <= 0) { b.x = 0; b.vx = Math.abs(b.vx); }
|
|
if (b.x >= W) { b.x = W; b.vx = -Math.abs(b.vx); }
|
|
} else {
|
|
if (b.x < -50 || b.x > W + 50) {
|
|
bullets.splice(i, 1);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Hit platforms
|
|
let hitPlatform = false;
|
|
for (const p of platforms) {
|
|
if (b.x > p.x && b.x < p.x + p.w && b.y > p.y && b.y < p.y + p.h) {
|
|
if (BULLET_BOUNCE) {
|
|
// Determine bounce direction
|
|
const overlapTop = b.y - p.y;
|
|
const overlapBottom = (p.y + p.h) - b.y;
|
|
const overlapLeft = b.x - p.x;
|
|
const overlapRight = (p.x + p.w) - b.x;
|
|
const minOverlap = Math.min(overlapTop, overlapBottom, overlapLeft, overlapRight);
|
|
if (minOverlap === overlapTop || minOverlap === overlapBottom) {
|
|
b.vy = -b.vy * 0.8;
|
|
b.y += b.vy;
|
|
} else {
|
|
b.vx = -b.vx * 0.8;
|
|
b.x += b.vx;
|
|
}
|
|
b.life -= 10; // lose life on bounce
|
|
// Spark particles
|
|
for (let j = 0; j < 3; j++) {
|
|
particles.push({
|
|
x: b.x, y: b.y,
|
|
vx: (Math.random() - 0.5) * 4,
|
|
vy: (Math.random() - 0.5) * 4,
|
|
life: 10 + Math.random() * 8,
|
|
color: '#f1c40f',
|
|
size: 2
|
|
});
|
|
}
|
|
} else {
|
|
hitPlatform = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (hitPlatform) {
|
|
// Impact particles
|
|
for (let j = 0; j < 4; j++) {
|
|
particles.push({
|
|
x: b.x, y: b.y,
|
|
vx: (Math.random() - 0.5) * 4,
|
|
vy: (Math.random() - 0.5) * 4,
|
|
life: 15 + Math.random() * 10,
|
|
color: '#aaa',
|
|
size: 2
|
|
});
|
|
}
|
|
bullets.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Hit players
|
|
for (const p of players) {
|
|
if (p === b.owner || !p.alive || p.invincible > 0) continue;
|
|
if (b.x > p.x && b.x < p.x + p.w && b.y > p.y && b.y < p.y + p.h) {
|
|
p.hp -= BULLET_DAMAGE;
|
|
p.hitFlash = 12;
|
|
// Blood particles
|
|
for (let j = 0; j < 15; j++) {
|
|
const angle = Math.atan2(b.vy, b.vx) + (Math.random() - 0.5) * 1.5;
|
|
const speed = 2 + Math.random() * 6;
|
|
particles.push({
|
|
x: b.x + (Math.random() - 0.5) * 10,
|
|
y: b.y + (Math.random() - 0.5) * 10,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed - Math.random() * 3,
|
|
life: 25 + Math.random() * 20,
|
|
color: Math.random() < 0.5 ? '#e74c3c' : '#c0392b',
|
|
size: 2 + Math.random() * 5
|
|
});
|
|
}
|
|
if (p.hp <= 0) {
|
|
p.die(b.owner);
|
|
}
|
|
// Knockback
|
|
p.vx += b.vx * 0.3;
|
|
p.vy += b.vy * 0.3 - 2;
|
|
bullets.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ammo pickups
|
|
for (let i = ammoPickups.length - 1; i >= 0; i--) {
|
|
const a = ammoPickups[i];
|
|
for (const p of players) {
|
|
if (!p.alive) continue;
|
|
const dx = (p.x + p.w / 2) - a.x;
|
|
const dy = (p.y + p.h / 2) - a.y;
|
|
if (Math.abs(dx) < 45 && Math.abs(dy) < 45) {
|
|
p.ammo = Math.min(MAX_AMMO, p.ammo + 3);
|
|
// Pickup effect
|
|
for (let j = 0; j < 8; j++) {
|
|
particles.push({
|
|
x: a.x, y: a.y,
|
|
vx: (Math.random() - 0.5) * 5,
|
|
vy: -Math.random() * 5,
|
|
life: 20,
|
|
color: '#f1c40f',
|
|
size: 3
|
|
});
|
|
}
|
|
ammoPickups.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spawn ammo
|
|
if (Date.now() - lastAmmoSpawn > AMMO_SPAWN_INTERVAL && ammoPickups.length < 5) {
|
|
spawnAmmoPickup();
|
|
lastAmmoSpawn = Date.now();
|
|
}
|
|
|
|
// Update particles
|
|
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.vx *= 0.98;
|
|
p.life--;
|
|
if (p.life <= 0) particles.splice(i, 1);
|
|
}
|
|
|
|
// HUD update every 6 frames
|
|
if (animFrame % 6 === 0) updateHUD();
|
|
}
|
|
|
|
// ============== GAME LOOP ==============
|
|
function gameLoop() {
|
|
if (!gameRunning) return;
|
|
update();
|
|
|
|
drawBackground();
|
|
drawPlatforms();
|
|
drawAmmoPickups();
|
|
for (const p of players) p.draw();
|
|
drawBullets();
|
|
drawParticles();
|
|
drawCrosshair();
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// ============== END GAME ==============
|
|
function endGame(winner) {
|
|
gameRunning = false;
|
|
document.getElementById('hud').style.display = 'none';
|
|
document.getElementById('gameOver').style.display = 'flex';
|
|
const isHuman = winner === players[0];
|
|
document.getElementById('endTitle').textContent = isHuman ? 'VICTOIRE !' : 'DEFAITE';
|
|
document.getElementById('endTitle').style.color = isHuman ? '#2ecc71' : '#e94560';
|
|
document.getElementById('winnerText').textContent = isHuman
|
|
? `${winner.char.name}, vous avez gagn\u00e9 !`
|
|
: `${winner.char.name} remporte la victoire !`;
|
|
document.getElementById('winnerText').style.color = winner.char.color;
|
|
}
|
|
|
|
function backToMenu() {
|
|
document.getElementById('gameOver').style.display = 'none';
|
|
document.getElementById('menu').style.display = 'flex';
|
|
}
|
|
|
|
// ============== POLYFILL ROUNDRECT ==============
|
|
if (!CanvasRenderingContext2D.prototype.roundRect) {
|
|
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
|
|
if (typeof r === 'number') r = [r, r, r, r];
|
|
this.moveTo(x + r[0], y);
|
|
this.lineTo(x + w - r[1], y);
|
|
this.arcTo(x + w, y, x + w, y + r[1], r[1]);
|
|
this.lineTo(x + w, y + h - r[2]);
|
|
this.arcTo(x + w, y + h, x + w - r[2], y + h, r[2]);
|
|
this.lineTo(x + r[3], y + h);
|
|
this.arcTo(x, y + h, x, y + h - r[3], r[3]);
|
|
this.lineTo(x, y + r[0]);
|
|
this.arcTo(x, y, x + r[0], y, r[0]);
|
|
this.closePath();
|
|
return this;
|
|
};
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|