944 lines
28 KiB
HTML
944 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MIS Runner - Micro Info Service</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
background: #1a1a2e;
|
|
display: flex; justify-content: center; align-items: center;
|
|
min-height: 100vh; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
#gameContainer { position: relative; }
|
|
canvas {
|
|
display: block; border: 3px solid #e94560; border-radius: 8px;
|
|
box-shadow: 0 0 30px rgba(233, 69, 96, 0.3);
|
|
}
|
|
#overlay {
|
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
|
background: rgba(0,0,0,0.85); border-radius: 8px; z-index: 10;
|
|
}
|
|
#overlay.hidden { display: none; }
|
|
#overlay h1 { color: #e94560; font-size: 38px; margin-bottom: 5px; text-shadow: 0 0 20px rgba(233,69,96,0.5); }
|
|
#overlay h2 { color: #2196F3; font-size: 20px; margin-bottom: 20px; font-weight: 300; }
|
|
#overlay p { color: #ccc; font-size: 15px; margin-bottom: 6px; }
|
|
.btn {
|
|
margin-top: 20px; padding: 14px 45px; font-size: 18px;
|
|
background: #e94560; color: white; border: none; border-radius: 50px;
|
|
cursor: pointer; transition: all 0.3s; font-weight: bold; letter-spacing: 1px;
|
|
}
|
|
.btn:hover { background: #ff6b81; transform: scale(1.05); box-shadow: 0 0 20px rgba(233,69,96,0.5); }
|
|
#score {
|
|
position: absolute; top: 12px; right: 20px;
|
|
color: white; font-size: 26px; font-weight: bold;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.5); z-index: 5;
|
|
}
|
|
#combo {
|
|
position: absolute; top: 45px; right: 20px;
|
|
color: #ffd700; font-size: 16px; font-weight: bold; z-index: 5;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="gameContainer">
|
|
<canvas id="c"></canvas>
|
|
<div id="score">0</div>
|
|
<div id="combo"></div>
|
|
<div id="overlay">
|
|
<h1>MIS RUNNER</h1>
|
|
<h2>Micro Info Service - Frejus</h2>
|
|
<p>ESPACE ou CLIC pour sauter</p>
|
|
<p>Double saut possible !</p>
|
|
<p>Bonne route !</p>
|
|
<button class="btn" id="startBtn">DEMARRER</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const canvas = document.getElementById('c');
|
|
const ctx = canvas.getContext('2d');
|
|
const W = 900, H = 500;
|
|
canvas.width = W; canvas.height = H;
|
|
|
|
const GROUND_Y = H - 60;
|
|
const GRAVITY = 0.6;
|
|
const JUMP_FORCE = -13;
|
|
const scoreEl = document.getElementById('score');
|
|
const comboEl = document.getElementById('combo');
|
|
const overlay = document.getElementById('overlay');
|
|
|
|
// ============ GAME STATE ============
|
|
let gameRunning = false;
|
|
let score = 0, bestScore = parseInt(localStorage.getItem('misRunnerBest')) || 0;
|
|
let combo = 0, comboTimer = 0;
|
|
let speed = 5;
|
|
let frameCount = 0;
|
|
let obstacles = [];
|
|
let platforms = [];
|
|
let particles = [];
|
|
let bloodSplats = [];
|
|
let clouds = [];
|
|
let groundOffset = 0;
|
|
|
|
// ============ VAN (vectorized MIS van) ============
|
|
const van = {
|
|
x: 100, y: GROUND_Y, w: 140, h: 52,
|
|
vy: 0, onGround: true, jumps: 0, maxJumps: 2,
|
|
wheelAngle: 0, squish: 0
|
|
};
|
|
|
|
function drawVan(x, y, w, h) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
|
|
// Squish effect on landing
|
|
let sx = 1 + van.squish * 0.15;
|
|
let sy = 1 - van.squish * 0.12;
|
|
ctx.scale(sx, sy);
|
|
|
|
const wheelR = 10;
|
|
const wheelY = -2;
|
|
const bodyBottom = -wheelR + 2; // body bottom clears wheels
|
|
const cabinW = w * 0.28; // front cabin portion
|
|
const cargoW = w - cabinW; // long cargo area behind cabin
|
|
const roofY = -h;
|
|
const beltLine = roofY + h * 0.45; // where windows end
|
|
|
|
// Shadow
|
|
ctx.fillStyle = 'rgba(0,0,0,0.25)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(w/2, 8, w/2 + 8, 5, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// ===== CARGO BODY (long flat box behind cabin) =====
|
|
ctx.fillStyle = '#EAEAEA';
|
|
ctx.beginPath();
|
|
ctx.moveTo(cabinW - 2, roofY);
|
|
ctx.lineTo(w - 3, roofY);
|
|
ctx.quadraticCurveTo(w, roofY, w, roofY + 3);
|
|
ctx.lineTo(w, bodyBottom);
|
|
ctx.lineTo(cabinW - 2, bodyBottom);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#bbb';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
// Cargo side panel lines (subtle ribs)
|
|
ctx.strokeStyle = '#d0d0d0';
|
|
ctx.lineWidth = 0.5;
|
|
for (let lx = cabinW + 20; lx < w - 10; lx += 25) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(lx, roofY + 3);
|
|
ctx.lineTo(lx, bodyBottom);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Sliding door track line
|
|
ctx.strokeStyle = '#bbb';
|
|
ctx.lineWidth = 1.2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cabinW + 8, roofY + 2);
|
|
ctx.lineTo(cabinW + 8, bodyBottom);
|
|
ctx.stroke();
|
|
|
|
// Rear door split line
|
|
ctx.beginPath();
|
|
ctx.moveTo(w - 2, roofY + 5);
|
|
ctx.lineTo(w - 2, bodyBottom);
|
|
ctx.stroke();
|
|
|
|
// ===== CABIN (front, slightly taller hood slope) =====
|
|
ctx.fillStyle = '#EAEAEA';
|
|
ctx.beginPath();
|
|
ctx.moveTo(3, beltLine + 4); // front bottom of windshield
|
|
ctx.quadraticCurveTo(0, beltLine, 2, roofY + 6); // windshield curve up
|
|
ctx.lineTo(8, roofY); // roof front edge
|
|
ctx.lineTo(cabinW, roofY); // roof to cargo join
|
|
ctx.lineTo(cabinW, bodyBottom); // down
|
|
ctx.lineTo(0, bodyBottom); // bottom
|
|
ctx.quadraticCurveTo(-3, bodyBottom, -5, bodyBottom + 3); // front bumper curve
|
|
ctx.lineTo(-5, beltLine + 8);
|
|
ctx.quadraticCurveTo(-4, beltLine + 2, 3, beltLine + 4);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#bbb';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
// ===== FRONT BUMPER =====
|
|
ctx.fillStyle = '#555';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-7, bodyBottom + 3);
|
|
ctx.lineTo(cabinW * 0.4, bodyBottom + 3);
|
|
ctx.lineTo(cabinW * 0.4, bodyBottom - 2);
|
|
ctx.lineTo(-5, bodyBottom - 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Renault-style front grille
|
|
ctx.fillStyle = '#444';
|
|
ctx.fillRect(-5, beltLine + 10, 10, bodyBottom - beltLine - 14);
|
|
// Grille slats
|
|
ctx.strokeStyle = '#666';
|
|
ctx.lineWidth = 0.8;
|
|
for (let gy = beltLine + 13; gy < bodyBottom - 4; gy += 3) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(-4, gy);
|
|
ctx.lineTo(4, gy);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// ===== WINDSHIELD =====
|
|
ctx.fillStyle = 'rgba(130,190,235,0.65)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(4, beltLine + 2);
|
|
ctx.quadraticCurveTo(3, beltLine - 2, 9, roofY + 2);
|
|
ctx.lineTo(cabinW - 4, roofY + 2);
|
|
ctx.lineTo(cabinW - 4, beltLine + 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#8ab';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
// Windshield divider (central pillar)
|
|
ctx.strokeStyle = '#bbb';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cabinW * 0.45, roofY + 3);
|
|
ctx.lineTo(cabinW * 0.4, beltLine + 2);
|
|
ctx.stroke();
|
|
|
|
// Side window (small, behind cabin)
|
|
ctx.fillStyle = 'rgba(130,190,235,0.45)';
|
|
ctx.fillRect(cabinW + 12, roofY + 4, 18, beltLine - roofY - 2);
|
|
ctx.strokeStyle = '#aaa';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.strokeRect(cabinW + 12, roofY + 4, 18, beltLine - roofY - 2);
|
|
|
|
// ===== ROOF RACK (dark bars on top like the photo) =====
|
|
ctx.fillStyle = '#2a2a2a';
|
|
// Two long rails
|
|
ctx.fillRect(12, roofY - 4, w - 20, 2.5);
|
|
ctx.fillRect(12, roofY - 8, w - 20, 2.5);
|
|
// Cross bars
|
|
ctx.fillStyle = '#3a3a3a';
|
|
for (let i = 0; i < 5; i++) {
|
|
const rx = 18 + i * ((w - 30) / 4);
|
|
ctx.fillRect(rx, roofY - 9, 3, 10);
|
|
}
|
|
// Items on rack (dark shapes like the photo - panels/equipment)
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.fillRect(25, roofY - 13, w * 0.5, 5);
|
|
ctx.fillStyle = '#333';
|
|
ctx.fillRect(25 + w * 0.52, roofY - 11, w * 0.2, 3);
|
|
|
|
// ===== MIS LOGO (blue box on cargo side) =====
|
|
const logoX = cabinW + 38;
|
|
const logoY = roofY + 6;
|
|
const logoW = 45;
|
|
const logoH = 20;
|
|
|
|
// Blue background
|
|
ctx.fillStyle = '#1565C0';
|
|
ctx.beginPath();
|
|
ctx.roundRect(logoX, logoY, logoW, logoH, 2);
|
|
ctx.fill();
|
|
// White border
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.stroke();
|
|
|
|
// "MIS" text
|
|
ctx.fillStyle = 'white';
|
|
ctx.font = 'bold 11px Arial';
|
|
ctx.fillText('MIS', logoX + 3, logoY + 12);
|
|
|
|
// "Micro Info Service" smaller
|
|
ctx.font = '5.5px Arial';
|
|
ctx.fillStyle = '#cde';
|
|
ctx.fillText('Micro Info Service', logoX + 3, logoY + 18);
|
|
|
|
// Secondary text below logo
|
|
ctx.fillStyle = '#777';
|
|
ctx.font = '5px Arial';
|
|
ctx.fillText('Geco Informatique', logoX + 2, logoY + 26);
|
|
|
|
// FREJUS + phone on lower cargo
|
|
ctx.fillStyle = '#555';
|
|
ctx.font = 'bold 5.5px Arial';
|
|
ctx.fillText('FREJUS', cabinW + 15, bodyBottom - 6);
|
|
ctx.font = '5px Arial';
|
|
ctx.fillText('04 94 55 08 23', cabinW + 15, bodyBottom - 1);
|
|
|
|
// Services text (like on the real van)
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '4.5px Arial';
|
|
ctx.fillText('MAINTENANCE INFORMATIQUE', cabinW + 5, bodyBottom + 3);
|
|
|
|
// ===== REAR LIGHTS =====
|
|
ctx.fillStyle = '#cc2222';
|
|
ctx.fillRect(w - 3, roofY + 10, 3, 10);
|
|
ctx.fillStyle = '#ff8800';
|
|
ctx.fillRect(w - 3, roofY + 22, 3, 6);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(w - 3, roofY + 30, 3, 4);
|
|
|
|
// ===== HEADLIGHTS =====
|
|
ctx.fillStyle = '#ffee88';
|
|
ctx.beginPath();
|
|
ctx.roundRect(-6, beltLine + 6, 5, 10, 2);
|
|
ctx.fill();
|
|
// Turn signal
|
|
ctx.fillStyle = '#ffaa33';
|
|
ctx.beginPath();
|
|
ctx.roundRect(-6, beltLine + 18, 5, 5, 1);
|
|
ctx.fill();
|
|
|
|
// Headlight glow cone
|
|
ctx.fillStyle = 'rgba(255,238,120,0.1)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-5, beltLine + 5);
|
|
ctx.lineTo(-40, beltLine - 15);
|
|
ctx.lineTo(-40, bodyBottom + 10);
|
|
ctx.lineTo(-5, beltLine + 20);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// ===== SIDE MIRROR =====
|
|
ctx.fillStyle = '#888';
|
|
ctx.beginPath();
|
|
ctx.moveTo(2, beltLine + 3);
|
|
ctx.lineTo(-10, beltLine);
|
|
ctx.lineTo(-12, beltLine + 6);
|
|
ctx.lineTo(-8, beltLine + 7);
|
|
ctx.lineTo(2, beltLine + 5);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Mirror glass
|
|
ctx.fillStyle = 'rgba(150,200,240,0.5)';
|
|
ctx.fillRect(-11, beltLine + 1, 3, 5);
|
|
|
|
// ===== WHEELS =====
|
|
van.wheelAngle += speed * 0.08;
|
|
|
|
// Wheel arches (dark cutouts)
|
|
[18, w - 22].forEach((wx, idx) => {
|
|
// Wheel arch
|
|
ctx.fillStyle = '#333';
|
|
ctx.beginPath();
|
|
ctx.arc(wx, wheelY, wheelR + 4, Math.PI, 0);
|
|
ctx.lineTo(wx + wheelR + 4, bodyBottom);
|
|
ctx.lineTo(wx - wheelR - 4, bodyBottom);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Tire
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.beginPath();
|
|
ctx.arc(wx, wheelY, wheelR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Tire tread edge
|
|
ctx.strokeStyle = '#111';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(wx, wheelY, wheelR, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Rim
|
|
ctx.fillStyle = '#999';
|
|
ctx.beginPath();
|
|
ctx.arc(wx, wheelY, wheelR * 0.55, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Hub
|
|
ctx.fillStyle = '#bbb';
|
|
ctx.beginPath();
|
|
ctx.arc(wx, wheelY, wheelR * 0.2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Spokes
|
|
ctx.strokeStyle = '#aaa';
|
|
ctx.lineWidth = 1.2;
|
|
for (let a = 0; a < 5; a++) {
|
|
const angle = van.wheelAngle + a * Math.PI * 2 / 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(wx + Math.cos(angle) * wheelR * 0.2, wheelY + Math.sin(angle) * wheelR * 0.2);
|
|
ctx.lineTo(wx + Math.cos(angle) * wheelR * 0.5, wheelY + Math.sin(angle) * wheelR * 0.5);
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
// ===== LOWER BODY TRIM (covers between wheels) =====
|
|
ctx.fillStyle = '#EAEAEA';
|
|
ctx.fillRect(18 + wheelR + 4, bodyBottom - 2, w - 40 - wheelR * 2 - 8, bodyBottom - wheelY + 4);
|
|
|
|
// Lower trim / skirt
|
|
ctx.fillStyle = '#888';
|
|
ctx.fillRect(18 + wheelR + 5, bodyBottom + 1, w - 40 - wheelR * 2 - 10, 2);
|
|
|
|
// ===== ORANGE STRAP (like in photo, cargo door) =====
|
|
ctx.strokeStyle = '#ff8800';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cabinW + 6, roofY + 2);
|
|
ctx.lineTo(cabinW + 3, bodyBottom);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ============ PETIT VIEUX / VIEILLE ============
|
|
function drawOldPerson(x, y, type, w, h) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
|
|
const isWoman = type === 1;
|
|
const skinColor = '#f5d0a9';
|
|
const hairColor = isWoman ? '#d4d4e8' : '#c8c8c8';
|
|
|
|
// Body
|
|
ctx.fillStyle = isWoman ? '#8B5E83' : '#5D6B3D';
|
|
ctx.fillRect(-w/3, -h * 0.55, w * 0.65, h * 0.4);
|
|
|
|
// Legs
|
|
ctx.fillStyle = isWoman ? '#7A4E73' : '#4A5530';
|
|
ctx.fillRect(-w/4, -h * 0.15, w * 0.2, h * 0.2);
|
|
ctx.fillRect(w/8, -h * 0.15, w * 0.2, h * 0.2);
|
|
|
|
// Shoes
|
|
ctx.fillStyle = '#3a3a3a';
|
|
ctx.fillRect(-w/4 - 2, -h * 0.02, w * 0.24, h * 0.06);
|
|
ctx.fillRect(w/8 - 2, -h * 0.02, w * 0.24, h * 0.06);
|
|
|
|
// Head
|
|
ctx.fillStyle = skinColor;
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.05, -h * 0.72, w * 0.28, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Hair
|
|
ctx.fillStyle = hairColor;
|
|
if (isWoman) {
|
|
// Curly hair bun
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.05, -h * 0.85, w * 0.25, 0, Math.PI, true);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.05, -h * 0.88, w * 0.15, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else {
|
|
// Balding with side hair
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.05, -h * 0.78, w * 0.29, Math.PI * 1.2, Math.PI * 1.8);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Glasses
|
|
ctx.strokeStyle = '#555';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.arc(-w * 0.06, -h * 0.72, 5, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.16, -h * 0.72, 5, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -h * 0.72);
|
|
ctx.lineTo(w * 0.1, -h * 0.72);
|
|
ctx.stroke();
|
|
|
|
// Eyes (behind glasses)
|
|
ctx.fillStyle = '#333';
|
|
ctx.beginPath();
|
|
ctx.arc(-w * 0.06, -h * 0.72, 1.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.16, -h * 0.72, 1.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Mouth (slightly open/confused)
|
|
ctx.strokeStyle = '#a0785a';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.05, -h * 0.63, 3, 0, Math.PI);
|
|
ctx.stroke();
|
|
|
|
// Cane (for men) or handbag (for women)
|
|
if (!isWoman) {
|
|
ctx.strokeStyle = '#8B4513';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(w/2 + 5, -h * 0.7);
|
|
ctx.quadraticCurveTo(w/2 + 8, -h * 0.75, w/2 + 3, -h * 0.8);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(w/2 + 5, -h * 0.7);
|
|
ctx.lineTo(w/2 + 5, 0);
|
|
ctx.stroke();
|
|
} else {
|
|
ctx.fillStyle = '#C2185B';
|
|
ctx.fillRect(-w/2 - 5, -h * 0.5, 12, 10);
|
|
ctx.strokeStyle = '#8B0F3A';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(-w/2 - 5, -h * 0.5, 12, 10);
|
|
// Strap
|
|
ctx.beginPath();
|
|
ctx.moveTo(-w/2 + 1, -h * 0.5);
|
|
ctx.quadraticCurveTo(-w/2 + 1, -h * 0.6, -w/3, -h * 0.55);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ============ BLOOD PARTICLES ============
|
|
function spawnBlood(x, y, amount) {
|
|
for (let i = 0; i < amount; i++) {
|
|
particles.push({
|
|
x: x + (Math.random() - 0.5) * 30,
|
|
y: y - Math.random() * 20,
|
|
vx: (Math.random() - 0.3) * 8 - speed * 0.5,
|
|
vy: -Math.random() * 8 - 2,
|
|
size: 2 + Math.random() * 5,
|
|
life: 1,
|
|
decay: 0.01 + Math.random() * 0.02,
|
|
color: Math.random() > 0.3 ? '#cc0000' : '#8B0000'
|
|
});
|
|
}
|
|
// Blood splat on ground
|
|
bloodSplats.push({
|
|
x: x, y: GROUND_Y,
|
|
w: 30 + Math.random() * 40,
|
|
life: 1, decay: 0.003
|
|
});
|
|
}
|
|
|
|
function updateParticles() {
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
const p = particles[i];
|
|
p.x += p.vx - speed * 0.3;
|
|
p.y += p.vy;
|
|
p.vy += 0.3;
|
|
p.life -= p.decay;
|
|
if (p.life <= 0 || p.x < -50) particles.splice(i, 1);
|
|
}
|
|
for (let i = bloodSplats.length - 1; i >= 0; i--) {
|
|
bloodSplats[i].x -= speed;
|
|
bloodSplats[i].life -= bloodSplats[i].decay;
|
|
if (bloodSplats[i].life <= 0 || bloodSplats[i].x < -100) bloodSplats.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function drawParticles() {
|
|
particles.forEach(p => {
|
|
ctx.globalAlpha = p.life;
|
|
ctx.fillStyle = p.color;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
bloodSplats.forEach(s => {
|
|
ctx.globalAlpha = s.life * 0.6;
|
|
ctx.fillStyle = '#8B0000';
|
|
ctx.beginPath();
|
|
ctx.ellipse(s.x, s.y, s.w / 2, 4, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// Additional droplets
|
|
ctx.fillStyle = '#cc0000';
|
|
ctx.beginPath();
|
|
ctx.ellipse(s.x + s.w * 0.2, s.y - 1, s.w * 0.15, 2, 0.3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ============ SCORE POPUP ============
|
|
let scorePopups = [];
|
|
function addScorePopup(x, y, pts) {
|
|
scorePopups.push({ x, y, pts, life: 1, vy: -2 });
|
|
}
|
|
|
|
// ============ OBSTACLES & PLATFORMS ============
|
|
function spawnObstacle() {
|
|
const type = Math.random() > 0.5 ? 1 : 0; // 0=homme, 1=femme
|
|
const onPlatform = Math.random() > 0.45;
|
|
|
|
if (onPlatform) {
|
|
const platY = GROUND_Y - 80 - Math.random() * 80;
|
|
const platW = 80 + Math.random() * 40;
|
|
platforms.push({
|
|
x: W + 50, y: platY, w: platW, h: 12
|
|
});
|
|
obstacles.push({
|
|
x: W + 50 + platW / 2 - 10,
|
|
y: platY,
|
|
w: 28, h: 48,
|
|
type, onPlatform: true, alive: true
|
|
});
|
|
} else {
|
|
obstacles.push({
|
|
x: W + 50,
|
|
y: GROUND_Y,
|
|
w: 28, h: 48,
|
|
type, onPlatform: false, alive: true
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============ CLOUDS ============
|
|
function spawnCloud() {
|
|
clouds.push({
|
|
x: W + 100,
|
|
y: 30 + Math.random() * 120,
|
|
w: 60 + Math.random() * 80,
|
|
speed: 0.3 + Math.random() * 0.5
|
|
});
|
|
}
|
|
|
|
// ============ COLLISION ============
|
|
function checkCollision(a, b) {
|
|
// a = van hitbox, b = obstacle
|
|
return a.x < b.x + b.w && a.x + a.w > b.x &&
|
|
a.y - a.h < b.y && a.y > b.y - b.h;
|
|
}
|
|
|
|
// ============ INPUT ============
|
|
let jumpPressed = false;
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
if (!gameRunning) { startGame(); return; }
|
|
tryJump();
|
|
}
|
|
});
|
|
canvas.addEventListener('click', () => {
|
|
if (!gameRunning) { startGame(); return; }
|
|
tryJump();
|
|
});
|
|
canvas.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
if (!gameRunning) { startGame(); return; }
|
|
tryJump();
|
|
});
|
|
|
|
function tryJump() {
|
|
if (van.jumps < van.maxJumps) {
|
|
van.vy = JUMP_FORCE * (van.jumps === 1 ? 0.85 : 1);
|
|
van.onGround = false;
|
|
van.jumps++;
|
|
}
|
|
}
|
|
|
|
// ============ GAME CONTROL ============
|
|
function startGame() {
|
|
gameRunning = true;
|
|
score = 0; combo = 0; comboTimer = 0;
|
|
speed = 5; frameCount = 0;
|
|
van.y = GROUND_Y; van.vy = 0; van.onGround = true; van.jumps = 0; van.squish = 0;
|
|
obstacles = []; platforms = []; particles = []; bloodSplats = [];
|
|
clouds = []; scorePopups = [];
|
|
overlay.classList.add('hidden');
|
|
for (let i = 0; i < 5; i++) spawnCloud();
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
function gameOver() {
|
|
gameRunning = false;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
localStorage.setItem('misRunnerBest', bestScore);
|
|
}
|
|
overlay.innerHTML = `
|
|
<h1>GAME OVER</h1>
|
|
<div style="color:#e94560;font-size:55px;font-weight:bold;text-shadow:0 0 20px rgba(233,69,96,0.5);">${score} pts</div>
|
|
<h2>${score > 50 ? 'Chauffard legendaire !' : score > 30 ? 'Sacre conducteur !' : score > 15 ? 'Pas mal !' : 'Permis revoque !'}</h2>
|
|
${score >= bestScore && score > 0 ? '<p style="color:#ffd700;font-size:18px;">NOUVEAU RECORD !</p>' : `<p style="color:#ccc;">Meilleur: ${bestScore} pts</p>`}
|
|
<button class="btn" onclick="startGame()">REJOUER</button>
|
|
<p style="margin-top:15px;color:#888;font-size:12px;">ESPACE ou CLIC pour recommencer</p>
|
|
`;
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
|
|
// ============ MAIN LOOP ============
|
|
let lastSpawn = 0;
|
|
let lastCloudSpawn = 0;
|
|
|
|
function gameLoop(timestamp) {
|
|
if (!gameRunning) return;
|
|
frameCount++;
|
|
|
|
// Speed increases over time
|
|
speed = 5 + frameCount * 0.002;
|
|
|
|
// ---- Update Van ----
|
|
van.vy += GRAVITY;
|
|
van.y += van.vy;
|
|
van.squish *= 0.85;
|
|
|
|
// Ground collision
|
|
if (van.y >= GROUND_Y) {
|
|
if (!van.onGround && van.vy > 2) van.squish = Math.min(1, van.vy / 15);
|
|
van.y = GROUND_Y;
|
|
van.vy = 0;
|
|
van.onGround = true;
|
|
van.jumps = 0;
|
|
}
|
|
|
|
// Platform collision (land on top)
|
|
let onPlatform = false;
|
|
platforms.forEach(p => {
|
|
if (van.vy >= 0 &&
|
|
van.x + van.w > p.x && van.x < p.x + p.w &&
|
|
van.y >= p.y - 3 && van.y <= p.y + 10 &&
|
|
van.y - van.h < p.y) {
|
|
van.y = p.y;
|
|
van.vy = 0;
|
|
van.onGround = true;
|
|
van.jumps = 0;
|
|
onPlatform = true;
|
|
if (!van.onGround) van.squish = 0.3;
|
|
}
|
|
});
|
|
|
|
// ---- Spawn obstacles ----
|
|
const spawnInterval = Math.max(40, 100 - frameCount * 0.03);
|
|
if (frameCount - lastSpawn > spawnInterval) {
|
|
spawnObstacle();
|
|
lastSpawn = frameCount;
|
|
}
|
|
|
|
// ---- Spawn clouds ----
|
|
if (frameCount - lastCloudSpawn > 200) {
|
|
spawnCloud();
|
|
lastCloudSpawn = frameCount;
|
|
}
|
|
|
|
// ---- Update obstacles ----
|
|
const vanHitbox = {
|
|
x: van.x + 5, y: van.y, w: van.w - 10, h: van.h - 5
|
|
};
|
|
|
|
for (let i = obstacles.length - 1; i >= 0; i--) {
|
|
const o = obstacles[i];
|
|
o.x -= speed;
|
|
|
|
if (o.alive && checkCollision(vanHitbox, o)) {
|
|
o.alive = false;
|
|
combo++;
|
|
comboTimer = 120;
|
|
const pts = 10 * combo;
|
|
score += pts;
|
|
addScorePopup(o.x, o.y - o.h - 10, pts);
|
|
spawnBlood(o.x + o.w/2, o.y, 25 + combo * 5);
|
|
}
|
|
|
|
if (o.x < -60) {
|
|
if (o.alive) { gameOver(); return; }
|
|
obstacles.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// ---- Update platforms ----
|
|
for (let i = platforms.length - 1; i >= 0; i--) {
|
|
platforms[i].x -= speed;
|
|
if (platforms[i].x < -150) platforms.splice(i, 1);
|
|
}
|
|
|
|
// ---- Update clouds ----
|
|
for (let i = clouds.length - 1; i >= 0; i--) {
|
|
clouds[i].x -= clouds[i].speed;
|
|
if (clouds[i].x < -150) clouds.splice(i, 1);
|
|
}
|
|
|
|
// ---- Combo timer ----
|
|
if (comboTimer > 0) {
|
|
comboTimer--;
|
|
if (comboTimer <= 0) combo = 0;
|
|
}
|
|
|
|
// ---- Update particles ----
|
|
updateParticles();
|
|
|
|
// ---- Score popups ----
|
|
for (let i = scorePopups.length - 1; i >= 0; i--) {
|
|
const sp = scorePopups[i];
|
|
sp.y += sp.vy;
|
|
sp.x -= speed * 0.5;
|
|
sp.life -= 0.015;
|
|
if (sp.life <= 0) scorePopups.splice(i, 1);
|
|
}
|
|
|
|
// ============ DRAW ============
|
|
// Sky gradient
|
|
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
|
|
skyGrad.addColorStop(0, '#87CEEB');
|
|
skyGrad.addColorStop(0.6, '#B8E4F0');
|
|
skyGrad.addColorStop(1, '#E8D5B7');
|
|
ctx.fillStyle = skyGrad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Sun
|
|
ctx.fillStyle = '#FFF3B0';
|
|
ctx.beginPath();
|
|
ctx.arc(750, 70, 40, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = 'rgba(255,243,176,0.15)';
|
|
ctx.beginPath();
|
|
ctx.arc(750, 70, 65, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Background mountains
|
|
ctx.fillStyle = '#a8c4a0';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, GROUND_Y);
|
|
for (let x = 0; x <= W; x += 60) {
|
|
ctx.lineTo(x, GROUND_Y - 40 - Math.sin(x * 0.008 + frameCount * 0.003) * 30);
|
|
}
|
|
ctx.lineTo(W, GROUND_Y);
|
|
ctx.fill();
|
|
|
|
// Background buildings (Frejus style)
|
|
ctx.fillStyle = '#d4b896';
|
|
const buildingOffX = (-frameCount * 1) % 300;
|
|
for (let i = 0; i < 6; i++) {
|
|
const bx = buildingOffX + i * 180;
|
|
const bh = 50 + Math.sin(i * 2.3) * 25;
|
|
ctx.fillRect(bx, GROUND_Y - bh, 40, bh);
|
|
ctx.fillRect(bx + 60, GROUND_Y - bh - 15, 35, bh + 15);
|
|
// Windows
|
|
ctx.fillStyle = 'rgba(100,180,220,0.5)';
|
|
for (let wy = 0; wy < 3; wy++) {
|
|
ctx.fillRect(bx + 8, GROUND_Y - bh + 10 + wy * 15, 8, 8);
|
|
ctx.fillRect(bx + 24, GROUND_Y - bh + 10 + wy * 15, 8, 8);
|
|
}
|
|
ctx.fillStyle = '#d4b896';
|
|
}
|
|
|
|
// Clouds
|
|
clouds.forEach(c => {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
ctx.beginPath();
|
|
ctx.arc(c.x, c.y, c.w * 0.25, 0, Math.PI * 2);
|
|
ctx.arc(c.x + c.w * 0.2, c.y - 8, c.w * 0.2, 0, Math.PI * 2);
|
|
ctx.arc(c.x + c.w * 0.4, c.y, c.w * 0.22, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
|
|
// Ground
|
|
ctx.fillStyle = '#666';
|
|
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
|
|
|
|
// Road surface
|
|
ctx.fillStyle = '#444';
|
|
ctx.fillRect(0, GROUND_Y, W, 35);
|
|
|
|
// Road lines
|
|
groundOffset = (groundOffset + speed) % 40;
|
|
ctx.fillStyle = '#fff';
|
|
ctx.setLineDash([]);
|
|
for (let x = -groundOffset; x < W; x += 40) {
|
|
ctx.fillRect(x, GROUND_Y + 16, 20, 3);
|
|
}
|
|
|
|
// Sidewalk edge
|
|
ctx.fillStyle = '#999';
|
|
ctx.fillRect(0, GROUND_Y - 2, W, 4);
|
|
|
|
// Blood splats on ground
|
|
drawParticles();
|
|
|
|
// ---- Platforms ----
|
|
platforms.forEach(p => {
|
|
// Platform shadow
|
|
ctx.fillStyle = 'rgba(0,0,0,0.15)';
|
|
ctx.fillRect(p.x + 4, p.y + 4, p.w, p.h);
|
|
// Platform body
|
|
const pGrad = ctx.createLinearGradient(0, p.y, 0, p.y + p.h);
|
|
pGrad.addColorStop(0, '#8D6E63');
|
|
pGrad.addColorStop(1, '#5D4037');
|
|
ctx.fillStyle = pGrad;
|
|
ctx.fillRect(p.x, p.y, p.w, p.h);
|
|
// Platform top edge
|
|
ctx.fillStyle = '#A1887F';
|
|
ctx.fillRect(p.x, p.y, p.w, 3);
|
|
// Support beams
|
|
ctx.fillStyle = '#4E342E';
|
|
ctx.fillRect(p.x + 5, p.y + p.h, 6, GROUND_Y - p.y - p.h);
|
|
ctx.fillRect(p.x + p.w - 11, p.y + p.h, 6, GROUND_Y - p.y - p.h);
|
|
});
|
|
|
|
// ---- Obstacles (petits vieux) ----
|
|
obstacles.forEach(o => {
|
|
if (o.alive) {
|
|
drawOldPerson(o.x + o.w/2, o.y, o.type, o.w, o.h);
|
|
}
|
|
});
|
|
|
|
// ---- Van ----
|
|
drawVan(van.x, van.y, van.w, van.h);
|
|
|
|
// ---- Score popups ----
|
|
scorePopups.forEach(sp => {
|
|
ctx.globalAlpha = sp.life;
|
|
ctx.fillStyle = '#ffd700';
|
|
ctx.font = 'bold 22px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(`+${sp.pts}`, sp.x, sp.y);
|
|
if (combo > 1) {
|
|
ctx.font = 'bold 14px Arial';
|
|
ctx.fillStyle = '#ff4444';
|
|
ctx.fillText(`x${combo} COMBO!`, sp.x, sp.y + 20);
|
|
}
|
|
ctx.textAlign = 'left';
|
|
ctx.globalAlpha = 1;
|
|
});
|
|
|
|
// ---- HUD ----
|
|
scoreEl.textContent = score;
|
|
if (combo > 1 && comboTimer > 0) {
|
|
comboEl.textContent = `COMBO x${combo}`;
|
|
comboEl.style.color = combo > 5 ? '#ff4444' : '#ffd700';
|
|
} else {
|
|
comboEl.textContent = '';
|
|
}
|
|
|
|
// Speed indicator
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.font = '12px Arial';
|
|
ctx.fillText(`${(speed * 10).toFixed(0)} km/h`, 15, 25);
|
|
|
|
// Best score
|
|
if (bestScore > 0) {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
ctx.font = '12px Arial';
|
|
ctx.fillText(`Record: ${bestScore}`, 15, 42);
|
|
}
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// Initial draw
|
|
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
|
|
skyGrad.addColorStop(0, '#87CEEB');
|
|
skyGrad.addColorStop(1, '#E8D5B7');
|
|
ctx.fillStyle = skyGrad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#444';
|
|
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
|
|
drawVan(van.x, GROUND_Y, van.w, van.h);
|
|
|
|
document.getElementById('startBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
startGame();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|