Files
Freeforall/freeforall.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 &mdash; 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 &nbsp;|&nbsp; <span>ESPACE / Z</span> Sauter (x2 = double saut) &nbsp;|&nbsp; <span>SOURIS</span> Viser &nbsp;|&nbsp; <span>CLIC GAUCHE</span> Tirer<br>
<span>S / &#8595;</span> Traverser la plateforme &nbsp;|&nbsp; 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>