Road Fighter v4.9 - Handbrake Ram AI Edition
by RocketKoala701950 lines82.3 KB
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Road Fighter v4.9 - Handbrake Ram AI Edition</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overflow: hidden; }
body {
background: #08080c; display: flex; flex-direction: column;
justify-content: center; align-items: center; min-height: 100vh;
font-family: 'Courier New', monospace; color: #fff; user-select: none;
}
#game-container {
position: relative; border: 4px solid #333;
box-shadow: 0 0 50px rgba(0,0,0,0.95);
background: #000; overflow: hidden;
}
canvas { display: block; }
.panel-info { margin-top: 10px; text-align: center; font-size: 13px; color: #bbb; line-height: 1.5; }
.panel-info b { color: #f1c40f; }
.music-slots {
margin-top: 8px; display: flex; gap: 8px; align-items: center;
background: #141721; padding: 8px 14px; border-radius: 6px;
border: 1px solid #2d3446; font-size: 11px;
}
.slot-btn { background: #2b334a; color: #fff; padding: 5px 9px; border-radius: 4px; border: 1px solid #4a5578; cursor: pointer; }
.slot-btn:hover { background: #3e4b6d; }
input[type="file"] { display: none; }
</style>
</head>
<body>
<div id="game-container"><canvas id="gameCanvas" width="620" height="660"></canvas></div>
<div class="music-slots">
<span>Слоты музыки:</span>
<label class="slot-btn" for="file1">🎵 Трек 1</label><input type="file" id="file1" accept="audio/*">
<label class="slot-btn" for="file2">🎵 Трек 2</label><input type="file" id="file2" accept="audio/*">
<label class="slot-btn" for="file3">🎵 Трек 3</label><input type="file" id="file3" accept="audio/*">
</div>
<div class="panel-info">
Меню: <b>Y</b> или <b>ESC / M</b> | Меню: <b>Стрелки</b> + <b>ENTER</b><br>
<b>P1:</b> Руль <b>Ф/В</b>, HIGH <b>Ц(W)</b>, LOW <b>Ы(S)</b>, Ручник <b>ПРОБЕЛ</b>, Трюк <b>E</b><br>
<b>P2:</b> IJKL (I=газ J=влево K=тормоз L=вправо) трюк <b>O</b> ручник <b>M</b><br>
<b>Сальто</b> = 5 сек нитро 480 | <b>Щит</b> = 355 | <b>За дорогой = смерть!</b><br>
<b>Розовая машина</b> умеет дёргать ручник для тарана!<br>
<b>Ъ</b> — рестарт
</div>
<audio id="bgAudio" loop></audio>
<script>
const GAME_VERSION = "v4.9";
const MAX_LIVES = 3;
const DIST_PX = 2.2;
const PLAYER_SCREEN_Y = 520;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let ROAD_LEFT = 145;
let ROAD_WIDTH = 330;
let currentRoadLeft = ROAD_LEFT;
let currentRoadWidth = ROAD_WIDTH;
function applyRoadDimensions() {
if (isTwoPlayer) { ROAD_LEFT = 65; ROAD_WIDTH = 180; }
else { ROAD_LEFT = 145; ROAD_WIDTH = 330; }
currentRoadLeft = ROAD_LEFT;
currentRoadWidth = ROAD_WIDTH;
}
const STAGES = [
{ name:'STAGE 1: ПРИГОРОД', distance:Math.round(25000*1.7*1.5), spawnRate:34, grassColor:'#0b5313', trafficSpeed:1.05, night:false, rain:false, hasBoss:false },
{ name:'STAGE 2: СКОРОСТНАЯ ТРАССА', distance:Math.round(30000*1.7*1.5), spawnRate:28, grassColor:'#192a56', trafficSpeed:1.25, night:false, rain:false, hasBoss:'tanker' },
{ name:'STAGE 3: АДСКИЙ КАНЬОН', distance:Math.round(36000*1.7*1.5), spawnRate:23, grassColor:'#4b1313', trafficSpeed:1.45, night:false, rain:false, hasBoss:'heli' },
{ name:'STAGE 4: НОЧНОЙ ЛИВЕНЬ', distance:Math.round(42000*1.7*1.5), spawnRate:19, grassColor:'#151b29', trafficSpeed:1.65, night:true, rain:true, hasBoss:false },
{ name:'STAGE 5: РЕМОНТНАЯ ЗОНА', distance:Math.round(44000*1.7*1.5), spawnRate:18, grassColor:'#634832', trafficSpeed:1.70, night:false, rain:false, hasBoss:false }
];
const TRAFFIC_PALETTE = ['#f1c40f','#00cec9','#e84393','#6c5ce7','#00b894','#fdcb6e','#fab1a0','#dfe6e9','#fd79a8','#ffeaa7'];
const CAR_CLASSES = [
{ name:"СПОРТКАР", color:"#ff2222", maxSpeed:410, accel:155, steer:235, fuelRate:1.0, stability:1.0, desc:"Сбалансированная скорость и манёвренность" },
{ name:"ДРИФТЕР", color:"#00e5ff", maxSpeed:425, accel:175, steer:260, fuelRate:1.15, stability:0.75, desc:"Быстрее разгон и руль, но строже к заносам" },
{ name:"ТЯЖЕЛОВЕС", color:"#e67e22", maxSpeed:385, accel:130, steer:205, fuelRate:0.85, stability:1.5, desc:"Тяжёлый кузов, стойкий к крену, экономит топливо" }
];
let selectedCarClassP1 = 0;
let selectedCarClassP2 = 1;
let unlockedStageIndex = parseInt(localStorage.getItem('road_fighter_checkpoint') || '0', 10);
if (unlockedStageIndex >= STAGES.length) unlockedStageIndex = 0;
let deathCount = parseInt(localStorage.getItem('road_fighter_deaths') || '0', 10);
if (isNaN(deathCount) || deathCount < 0) deathCount = 0;
let livesEnabled = true;
let currentLevel = unlockedStageIndex;
let isTwoPlayer = false;
let gameState = 'PRESS_START';
let menuSelection = 0;
let gameOverSelection = 0;
let selectedMusicTrack = 1;
let timeLeft = 90;
let roadScroll = 0;
let screenShakeTimer = 0;
let screenShakeMagnitude = 0;
let skidmarks = [];
let roadNarrowTimer = 0;
let roadNarrowPhase = 0;
let roadConeProps = [];
let activeBoss = null;
let highScores = JSON.parse(localStorage.getItem('road_fighter_records') || '[]');
function p2KeyMap() {
return {
left: ['KeyJ', 'ArrowLeft', 'Numpad4', 'Digit4', 'Home'],
right: ['KeyL', 'ArrowRight', 'Numpad6', 'Digit6', 'End'],
low: ['KeyK', 'ArrowDown', 'Numpad2', 'Numpad5', 'Digit2', 'Digit5'],
high: ['KeyI', 'ArrowUp', 'Numpad8', 'Digit8'],
handbrake:['KeyM', 'Numpad0', 'ShiftRight', 'NumpadDecimal']
};
}
function p2StuntKeys(code, key) {
return code === 'KeyO' || key === 'o' || key === 'щ' ||
code === 'Slash' || code === 'NumpadDivide' || code === 'NumpadMultiply' ||
key === '/' || key === '*';
}
function saveHighScore(score, timeSpent) {
highScores.push({ score, time: Math.round(timeSpent), date: new Date().toLocaleDateString() });
highScores.sort((a,b)=>b.score-a.score);
highScores = highScores.slice(0,5);
localStorage.setItem('road_fighter_records', JSON.stringify(highScores));
}
function triggerScreenShake(d, m) { screenShakeTimer = d; screenShakeMagnitude = m; }
function getLivesLeft() { return Math.max(0, MAX_LIVES - deathCount); }
function resetDeaths() { deathCount = 0; localStorage.setItem('road_fighter_deaths', '0'); }
// ===== AUDIO =====
const bgAudio = document.getElementById('bgAudio');
const trackUrls = { 1:null, 2:null, 3:null };
function setupAudioLoader(id, n) {
document.getElementById(id).addEventListener('change', e => {
const f = e.target.files[0]; if (!f) return;
if (trackUrls[n]) URL.revokeObjectURL(trackUrls[n]);
trackUrls[n] = URL.createObjectURL(f);
if (selectedMusicTrack === n) playSelectedMusic();
});
}
setupAudioLoader('file1',1); setupAudioLoader('file2',2); setupAudioLoader('file3',3);
function playSelectedMusic() {
if (selectedMusicTrack === 0) { bgAudio.pause(); return; }
const url = trackUrls[selectedMusicTrack];
if (url) { bgAudio.src = url; bgAudio.volume = 0.55; bgAudio.play().catch(()=>{}); }
else bgAudio.pause();
}
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let engineOsc = null, engineGain = null;
function initEngineSound() {
try {
engineOsc = audioCtx.createOscillator();
engineGain = audioCtx.createGain();
engineOsc.type = 'sawtooth';
engineOsc.frequency.setValueAtTime(42, audioCtx.currentTime);
engineGain.gain.setValueAtTime(0.012, audioCtx.currentTime);
engineOsc.connect(engineGain); engineGain.connect(audioCtx.destination);
engineOsc.start();
} catch(e){}
}
function updateEngineSound(s) {
if (!engineOsc) return;
engineOsc.frequency.setTargetAtTime(45 + (s/410)*105, audioCtx.currentTime, 0.05);
}
function playSfx(f, d, t='square', ramp=0.001) {
try {
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.type = t; o.frequency.setValueAtTime(f, audioCtx.currentTime);
g.gain.setValueAtTime(0.08, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(ramp, audioCtx.currentTime + d);
o.connect(g); g.connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + d);
} catch(e){}
}
let synthTick = 0;
const synthNotes = [220, 220, 261.6, 293.7, 329.6, 293.7, 261.6, 196];
function updateSynthMusic() {
if (selectedMusicTrack === 0 || trackUrls[selectedMusicTrack]) return;
synthTick++;
if (synthTick % 18 === 0 && (gameState === 'PLAY' || gameState === 'MENU')) {
const n = synthNotes[Math.floor(synthTick/18) % synthNotes.length];
playSfx(n, 0.12, 'triangle', 0.015);
}
}
// ===== PLAYER =====
function createPlayer(id, x, classIndex, keyMap) {
const cc = CAR_CLASSES[classIndex];
return {
id, x, y: PLAYER_SCREEN_Y, w: 24, h: 46,
color: cc.color, carClass: cc, keyMap,
dist: 0, catchUpBoost: 1.0,
speed: 0, fuel: 100, score: 0,
crashed: false, crashTimer: 0,
tiltAngle: 0, driftVel: 0, angularVel: 0, tiltGraceTimer: 0, isSpinningOut: false,
spinCharges: 2, spinRechargeTimer: 0,
stuntAngle: 0, isDoingStunt360: false, stuntDir: 1, stuntTimer: 0, stuntDuration: 0.2, stuntDecelTarget: 0,
isDoingFlip: false, flipScaleY: 1.0,
dashDir: 0, dashTimer: 0, lastLeftTapTime: 0, lastRightTapTime: 0,
shieldTimer: 0, nitroTimer: 0, invulnerableTimer: 0,
jumpTimer: 0, jumpScale: 1.0,
rainbowOilTimer: 0, isInTurboTunnel: false,
handbrakeDebuffTotal: 0, handbrakeCooldown: 0
};
}
let players = [];
let traffic = [];
let pickups = [];
let bonusCars = [];
let obstacles = [];
let ramps = [];
let cops = [];
let pitVans = [];
let tunnels = [];
let raindrops = [];
let particles = [];
let stageTimePassed = 0;
function syFor(worldDist, playerDist) {
return PLAYER_SCREEN_Y - (worldDist - playerDist) * DIST_PX;
}
function collidesPlayerObj(p, obj) {
const objY = syFor(obj.dist, p.dist);
return !(p.x + p.w < obj.x || p.x > obj.x + obj.w || p.y + p.h < objY || p.y > objY + obj.h);
}
// ===== KEYS =====
const activeKeys = {};
const PREVENT_DEFAULT_CODES = new Set([
'ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Slash','NumpadDivide','NumpadMultiply',
'Numpad0','Numpad1','Numpad2','Numpad4','Numpad5','Numpad6','Numpad8','NumpadDecimal',
'Home','End',
'Backquote','Enter','Escape',
'KeyW','KeyA','KeyS','KeyD','KeyE','KeyY','KeyU','KeyM','KeyC','KeyF','KeyZ',
'KeyI','KeyJ','KeyK','KeyL','KeyO',
'Digit2','Digit4','Digit5','Digit6','Digit8'
]);
window.addEventListener('keydown', e => {
const key = e.key ? e.key.toLowerCase() : '';
const code = e.code || '';
activeKeys[code] = true;
if (key) activeKeys[key] = true;
if (PREVENT_DEFAULT_CODES.has(code)) e.preventDefault();
if (e.repeat) return;
if (code === 'Backquote' || key === 'ъ' || key === '~' || key === 'ё') {
if (audioCtx.state === 'suspended') audioCtx.resume();
if (!engineOsc) initEngineSound();
if (players.length === 0) initPlayersForRestart();
quickRestart();
return;
}
if (code === 'KeyY' || key === 'y' || key === 'н' || code === 'KeyU' || key === 'u' || key === 'г' || code === 'Escape' || code === 'KeyM' || key === 'ь') {
if (audioCtx.state === 'suspended') audioCtx.resume();
if (!engineOsc) initEngineSound();
gameState = 'MENU'; playSelectedMusic(); playSfx(420, 0.15);
return;
}
if (gameState === 'PRESS_START') {
if (audioCtx.state === 'suspended') audioCtx.resume();
if (!engineOsc) initEngineSound();
gameState = 'MENU'; playSelectedMusic(); playSfx(420, 0.15);
return;
}
if (gameState === 'MENU') {
const totalMenuRows = isTwoPlayer ? 6 : 5;
const startRowIndex = isTwoPlayer ? 5 : 4;
if (code === 'ArrowUp' || code === 'KeyW' || key === 'ц') { menuSelection = (menuSelection - 1 + totalMenuRows) % totalMenuRows; playSfx(330, 0.08); }
if (code === 'ArrowDown' || code === 'KeyS' || key === 'ы') { menuSelection = (menuSelection + 1) % totalMenuRows; playSfx(330, 0.08); }
if (code === 'ArrowLeft' || code === 'KeyA' || key === 'ф') handleMenuAdjust(-1);
if (code === 'ArrowRight' || code === 'KeyD' || key === 'в') handleMenuAdjust(1);
if (code === 'Enter') {
if (menuSelection === startRowIndex) startGame(); else handleMenuAdjust(1);
playSfx(480, 0.1);
}
}
if (gameState === 'GAMEOVER') {
if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'KeyW' || code === 'KeyS' || key === 'ц' || key === 'ы') {
gameOverSelection = 1 - gameOverSelection; playSfx(330, 0.08);
}
if (code === 'Enter' || code === 'Space') {
if (gameOverSelection === 0) loadStage(currentLevel);
else { currentLevel = 0; localStorage.setItem('road_fighter_checkpoint','0'); resetDeaths(); loadStage(0); }
gameState = 'PLAY'; playSfx(520, 0.2);
}
}
if (gameState === 'ALL_LIVES_LOST') {
if (code === 'Enter' || code === 'Space') {
currentLevel = 0; localStorage.setItem('road_fighter_checkpoint','0'); resetDeaths();
loadStage(0); gameState = 'PLAY'; playSfx(520, 0.2);
}
}
if (gameState === 'PLAY') {
if (code === 'KeyE' || key === 'e' || key === 'у') triggerEAction(players[0]);
if (players[1] && p2StuntKeys(code, key)) triggerEAction(players[1]);
const now = performance.now();
if (players[0]) {
if (code === 'KeyA' || code === 'KeyC' || key === 'ф' || key === 'с') {
if (now - players[0].lastLeftTapTime < 280) performSideBash(players[0], -1);
players[0].lastLeftTapTime = now;
}
if (code === 'KeyD' || code === 'KeyF' || key === 'в' || key === 'f') {
if (now - players[0].lastRightTapTime < 280) performSideBash(players[0], 1);
players[0].lastRightTapTime = now;
}
}
}
if (gameState === 'STAGE_CLEAR') {
if (code === 'Enter' || code === 'Space') {
currentLevel++;
if (currentLevel >= STAGES.length) currentLevel = 0;
localStorage.setItem('road_fighter_checkpoint', currentLevel.toString());
loadStage(currentLevel); gameState = 'PLAY';
}
} else if (gameState === 'VICTORY') {
if (code === 'Enter' || code === 'Space') { resetDeaths(); gameState = 'MENU'; }
}
});
function handleMenuAdjust(dir) {
if (menuSelection === 0) { isTwoPlayer = !isTwoPlayer; if (!isTwoPlayer && menuSelection > 4) menuSelection = 4; }
else if (menuSelection === 1) livesEnabled = !livesEnabled;
else if (menuSelection === 2) selectedCarClassP1 = (selectedCarClassP1 + dir + CAR_CLASSES.length) % CAR_CLASSES.length;
else if (isTwoPlayer && menuSelection === 3) selectedCarClassP2 = (selectedCarClassP2 + dir + CAR_CLASSES.length) % CAR_CLASSES.length;
else if ((!isTwoPlayer && menuSelection === 3) || (isTwoPlayer && menuSelection === 4)) {
selectedMusicTrack = (selectedMusicTrack + dir + 4) % 4; playSelectedMusic();
}
}
window.addEventListener('keyup', e => {
const key = e.key ? e.key.toLowerCase() : '';
const code = e.code || '';
activeKeys[code] = false;
if (key) activeKeys[key] = false;
});
function clearAllKeys() { for (const k in activeKeys) activeKeys[k] = false; }
window.addEventListener('blur', clearAllKeys);
window.addEventListener('visibilitychange', () => { if (document.hidden) clearAllKeys(); });
function isKeyActive(l) { return l.some(k => activeKeys[k]); }
// ===== ACTIONS =====
function triggerEAction(p) {
if (!p || p.crashed) return;
if (p.jumpTimer > 0) performFlip(p); else performStunt360(p);
}
function performSideBash(p, dir) {
if (!p || p.crashed || p.dashTimer > 0 || p.isSpinningOut) return;
p.dashDir = dir; p.dashTimer = 0.2;
p.invulnerableTimer = Math.max(p.invulnerableTimer, 0.25);
playSfx(500, 0.15, 'sawtooth');
for (let i = 0; i < 8; i++) {
particles.push({ x: p.x + (dir<0?0:p.w), y: p.y + p.h/2 + (Math.random()-0.5)*15,
vx: dir*(3+Math.random()*4), vy: (Math.random()-0.5)*2, dist: p.dist,
size: 3+Math.random()*3, life: 14, color: '#fff' });
}
}
function performStunt360(p) {
if (!p || p.crashed || p.isDoingStunt360 || p.isSpinningOut || p.speed <= 30) return;
if (p.spinCharges <= 0) { playSfx(120, 0.15, 'square'); return; }
p.spinCharges--;
if (p.spinRechargeTimer <= 0) p.spinRechargeTimer = 10.0;
let dir = Math.random() > 0.5 ? 1 : -1;
if (isKeyActive(p.keyMap.left)) dir = -1;
else if (isKeyActive(p.keyMap.right) || isKeyActive(p.keyMap.high)) dir = 1;
p.isDoingStunt360 = true;
p.stuntDir = dir; p.stuntAngle = 0; p.stuntTimer = 0.2; p.stuntDuration = 0.2;
p.stuntDecelTarget = p.speed * 0.96;
p.invulnerableTimer = Math.max(p.invulnerableTimer, 3.0);
playSfx(540, 0.2, 'sine');
}
function performFlip(p) {
if (!p || p.crashed || p.jumpTimer <= 0 || p.isDoingFlip) return;
p.isDoingFlip = true; p.flipScaleY = 1.0;
p.nitroTimer = Math.max(p.nitroTimer, 5.0);
p.speed = Math.max(p.speed, 480);
playSfx(680, 0.3, 'sine');
}
// ===== START =====
function initPlayersForRestart() {
applyRoadDimensions();
players = [ createPlayer(1, ROAD_LEFT + (isTwoPlayer ? 50 : ROAD_WIDTH/2 - 12), selectedCarClassP1, {
left: ['KeyA','KeyC','ф','с'], right: ['KeyD','KeyF','в','f','а'],
low: ['KeyS','KeyZ','ы','я'], high: ['KeyW','ц'], handbrake: ['Space']
})];
if (isTwoPlayer) {
players.push(createPlayer(2, ROAD_LEFT + (ROAD_WIDTH - 74), selectedCarClassP2, p2KeyMap()));
}
}
function quickRestart() {
currentLevel = 0; unlockedStageIndex = 0;
localStorage.setItem('road_fighter_checkpoint','0'); resetDeaths();
initPlayersForRestart();
stageTimePassed = 0; loadStage(0); gameState = 'PLAY';
triggerScreenShake(0.5, 8); playSfx(300, 0.3, 'triangle'); playSfx(520, 0.3, 'sine');
}
function startGame() {
if (!engineOsc) initEngineSound();
playSelectedMusic();
applyRoadDimensions();
stageTimePassed = 0;
initPlayersForRestart();
if (!livesEnabled) resetDeaths();
loadStage(currentLevel);
gameState = 'PLAY';
playSfx(520, 0.25);
}
function loadStage(lvl) {
// +15% времени от базового + 10 секунд дополнительно
timeLeft = Math.floor((100 + Math.floor(Math.random() * 21)) * 1.15) + 10;
applyRoadDimensions();
traffic = []; pickups = []; bonusCars = []; obstacles = []; ramps = [];
cops = []; pitVans = []; tunnels = []; particles = []; raindrops = [];
skidmarks = []; roadConeProps = []; roadNarrowPhase = 0; activeBoss = null;
roadScroll = 0;
const stage = STAGES[lvl];
if (stage.rain) {
for (let i = 0; i < 90; i++) {
raindrops.push({ x: Math.random()*canvas.width, y: Math.random()*canvas.height,
len: 12+Math.random()*8, speed: 16+Math.random()*8 });
}
}
players.forEach((p, idx) => {
p.x = ROAD_LEFT + (isTwoPlayer ? (idx === 0 ? 40 : ROAD_WIDTH - 64) : ROAD_WIDTH/2 - 12);
p.dist = 0; p.speed = 0; p.tiltAngle = 0; p.driftVel = 0; p.angularVel = 0;
p.tiltGraceTimer = 0; p.isSpinningOut = false; p.crashed = false;
p.shieldTimer = 0; p.nitroTimer = 0; p.jumpTimer = 0; p.jumpScale = 1.0;
p.isDoingStunt360 = false; p.stuntAngle = 0; p.stuntTimer = 0;
p.spinCharges = 2; p.spinRechargeTimer = 0; p.dashTimer = 0;
p.isDoingFlip = false; p.rainbowOilTimer = 0; p.isInTurboTunnel = false;
p.invulnerableTimer = 0; p.handbrakeDebuffTotal = 0; p.handbrakeCooldown = 0;
p.fuel = 100; p.catchUpBoost = 1.0;
});
}
// ===== SPAWN =====
let ticker = 0;
function spawnWorld() {
const stage = STAGES[currentLevel];
ticker++;
const maxP = players.reduce((m,p)=>Math.max(m,p.dist), 0);
if (stage.hasBoss && !activeBoss && maxP > stage.distance * 0.68) {
if (stage.hasBoss === 'tanker') {
activeBoss = { type:'tanker', x: currentRoadLeft + 30, dist: maxP + 220,
w: 68, h: 120, speed: 140, hp: 12, actionTick: 0 };
playSfx(220, 0.6, 'sawtooth');
} else if (stage.hasBoss === 'heli') {
activeBoss = { type:'heli', x: (isTwoPlayer ? 155 : canvas.width/2) - 35, dist: maxP + 220,
w: 70, h: 90, speed: 155, hp: 10, actionTick: 0, rotorAngle: 0 };
playSfx(620, 0.6, 'sine');
}
}
const isFifthLevel = (currentLevel + 1) % 5 === 0;
if (isFifthLevel && roadNarrowPhase === 0 && Math.random() < 0.003) {
roadNarrowPhase = 1; roadNarrowTimer = 9.0;
}
if (ticker % stage.spawnRate === 0) {
const laneX = currentRoadLeft + 24 + Math.random() * (currentRoadWidth - 72);
const tooClose = traffic.some(c => Math.abs(c.dist - maxP) < 120 && Math.abs(c.x - laneX) < 45);
if (!tooClose) {
const r = Math.random();
const color = TRAFFIC_PALETTE[Math.floor(Math.random()*TRAFFIC_PALETTE.length)];
const isCounter = (laneX < currentRoadLeft + currentRoadWidth/3) && (Math.random() < 0.35);
const spawnDist = maxP + 300;
const base = {
x: laneX, dist: spawnDist, w: 24, h: 44,
speed: isCounter ? -160 : 130 * stage.trafficSpeed,
color: isCounter ? '#ff3838' : color,
type: isCounter ? 'counter' : 'car',
tiltAngle: isCounter ? Math.PI : 0,
driftVel: 0, angularVel: 0, isSpinning: false,
isRevengeReady: false, revengeTimer: 0, revengeAttempts: 0, revengeActive: false,
revengeTactic: 'BLOCK', ambushTriggered: false,
angryRacer: Math.random() < 0.15, angryTriggered: false,
isPurpleAgressor: false, purpleCooldown: 0,
cutByPlayer: false, cutTimer: 0, isRevengeAI: false
};
if (isCounter) traffic.push(base);
else if (r < 0.20) traffic.push(base);
else if (r < 0.38) { base.speed = 165 * stage.trafficSpeed; base.color = '#0984e3'; base.type = 'shifter'; base.dir = Math.random()>0.5?1:-1; traffic.push(base); }
else if (r < 0.52) { base.speed = 145 * stage.trafficSpeed; base.color = '#8e44ad'; base.type = 'purple'; base.isPurpleAgressor = true; traffic.push(base); }
else if (r < 0.64) { base.speed = 110 * stage.trafficSpeed; base.color = '#e17055'; base.type = 'taxi'; traffic.push(base); }
else if (r < 0.74) { base.w = 28; base.h = 66; base.speed = 95 * stage.trafficSpeed; base.color = '#ff6b00'; base.type = 'truck'; traffic.push(base); }
else if (r < 0.82) obstacles.push({ x: laneX, dist: spawnDist, w: 32, h: 22, type: 'oil_black' });
else if (r < 0.90) obstacles.push({ x: laneX, dist: spawnDist, w: 34, h: 24, type: 'oil_rainbow', pulse: 0 });
else if (r < 0.95) obstacles.push({ x: laneX, dist: spawnDist, w: 24, h: 22, type: 'rock' });
else ramps.push({ x: laneX, dist: spawnDist, w: 30, h: 24 });
}
if (pitVans.length === 0 && Math.random() < 0.05) {
pitVans.push({ x: currentRoadLeft + 25 + Math.random()*(currentRoadWidth-85),
dist: maxP + 300, w: 32, h: 75, speed: 120, arrowTick: 0 });
}
if (tunnels.length === 0 && Math.random() < 0.04) {
tunnels.push({ dist: maxP + 900, height: 900, neonTick: 0 });
}
if (cops.length === 0 && Math.random() < 0.08 && currentLevel >= 1) {
cops.push({ x: currentRoadLeft + (Math.random()>0.5 ? 20 : currentRoadWidth-44),
dist: maxP - 200, w: 24, h: 46, speed: 460, sirenTick: 0 });
playSfx(780, 0.4, 'sawtooth');
}
const bonusRoll = Math.random();
if (bonusRoll < 0.40) {
const bX = currentRoadLeft + 22 + Math.random() * (currentRoadWidth - 64);
const bR = Math.random();
let bType = 'FUEL';
if (bR > 0.75) { const o = Math.random(); if (o > 0.66) bType = 'NITRO'; else if (o > 0.33) bType = 'TIME'; else bType = 'SHIELD'; }
pickups.push({ x: bX, dist: maxP + 300, w: 22, h: 22, type: bType, pulse: 0 });
} else if (bonusRoll < 0.46) {
const bX = currentRoadLeft + 22 + Math.random() * (currentRoadWidth - 64);
bonusCars.push({ x: bX, dist: maxP + 300, w: 22, h: 42, speed: 135, tick: 0 });
}
}
}
// ===== EXPLOSIONS =====
function createExplosion(x, y, worldDist) {
triggerScreenShake(0.35, 6);
playSfx(75, 0.45, 'sawtooth');
for (let i = 0; i < 28; i++) {
const a = Math.random() * Math.PI * 2;
const s = 2 + Math.random() * 6;
particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s, dist: worldDist,
size: 4+Math.random()*5, life: 30+Math.random()*18,
color: ['#ff1111','#ff9900','#ffff00','#ffffff'][Math.floor(Math.random()*4)] });
}
}
function emitSpeedFire(p) {
for (let i = 0; i < 2; i++) {
const ox = (Math.random()-0.5)*14;
particles.push({ x: p.x + p.w/2 + ox, y: p.y + p.h - 2,
vx: (Math.random()-0.5)*1.5, vy: 4+Math.random()*6, dist: p.dist,
size: 3+Math.random()*5, life: 14+Math.random()*8,
color: ['#ff1e00','#ff6100','#ffb700','#ffffff'][Math.floor(Math.random()*4)] });
}
}
function createPitStopSparkles(x, y, worldDist) {
for (let i = 0; i < 20; i++) {
const a = Math.random() * Math.PI * 2;
const s = 2 + Math.random() * 5;
particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s, dist: worldDist,
size: 3+Math.random()*4, life: 25+Math.random()*10,
color: ['#00ff66','#00e5ff','#ffffff'][Math.floor(Math.random()*3)] });
}
}
function updateParticles() {
for (let i = particles.length - 1; i >= 0; i--) {
const pt = particles[i];
pt.x += pt.vx; pt.y += pt.vy; pt.life--;
if (pt.life <= 0) particles.splice(i, 1);
}
}
// ===== UPDATE =====
function update(dt) {
if (gameState !== 'PLAY') return;
const stage = STAGES[currentLevel];
updateSynthMusic();
stageTimePassed += dt;
if (screenShakeTimer > 0) screenShakeTimer -= dt;
timeLeft -= dt;
if (timeLeft <= 0) {
timeLeft = 0;
saveHighScore(players[0].score, stageTimePassed);
if (livesEnabled) {
deathCount++;
localStorage.setItem('road_fighter_deaths', deathCount.toString());
if (deathCount >= MAX_LIVES) {
currentLevel = 0; localStorage.setItem('road_fighter_checkpoint','0');
gameState = 'ALL_LIVES_LOST'; playSfx(90, 0.8, 'sawtooth');
} else gameState = 'GAMEOVER';
} else gameState = 'GAMEOVER';
return;
}
if ((currentLevel + 1) % 5 === 0) {
if (roadNarrowPhase === 1) {
roadNarrowTimer -= dt;
currentRoadWidth = Math.max(isTwoPlayer ? 110 : 170, currentRoadWidth - 65 * dt);
currentRoadLeft = ROAD_LEFT + (ROAD_WIDTH - currentRoadWidth) / 2;
if (Math.random() < 0.12) {
roadConeProps.push({ x: currentRoadLeft - 8, dist: players[0].dist + 300 });
roadConeProps.push({ x: currentRoadLeft + currentRoadWidth + 4, dist: players[0].dist + 300 });
}
if (roadNarrowTimer <= 0) roadNarrowPhase = 2;
} else if (roadNarrowPhase === 2) {
currentRoadWidth = Math.min(ROAD_WIDTH, currentRoadWidth + 65 * dt);
currentRoadLeft = ROAD_LEFT + (ROAD_WIDTH - currentRoadWidth) / 2;
if (currentRoadWidth >= ROAD_WIDTH) { currentRoadWidth = ROAD_WIDTH; currentRoadLeft = ROAD_LEFT; roadNarrowPhase = 0; }
}
} else { currentRoadLeft = ROAD_LEFT; currentRoadWidth = ROAD_WIDTH; }
if (stage.rain) {
for (let d of raindrops) { d.y += d.speed; d.x -= 2; if (d.y > canvas.height) { d.y = -10; d.x = Math.random()*canvas.width; } }
}
if (isTwoPlayer && players.length === 2) {
if (players[0].dist > players[1].dist + 5) { players[0].catchUpBoost = 1.0; players[1].catchUpBoost = 1.10; }
else if (players[1].dist > players[0].dist + 5) { players[0].catchUpBoost = 1.10; players[1].catchUpBoost = 1.0; }
else { players[0].catchUpBoost = 1.0; players[1].catchUpBoost = 1.0; }
} else if (players[0]) players[0].catchUpBoost = 1.0;
let highestSpeed = 0;
players.forEach(p => {
p.isInTurboTunnel = tunnels.some(t => p.dist >= t.dist && p.dist <= t.dist + t.height / DIST_PX);
});
players.forEach(p => {
if (p.shieldTimer > 0) p.shieldTimer -= dt;
if (p.nitroTimer > 0) p.nitroTimer -= dt;
if (p.invulnerableTimer > 0) p.invulnerableTimer -= dt;
if (p.handbrakeCooldown > 0) p.handbrakeCooldown -= dt;
if (p.rainbowOilTimer > 0) p.rainbowOilTimer -= dt;
if (p.spinCharges < 2) {
p.spinRechargeTimer -= dt;
if (p.spinRechargeTimer <= 0) { p.spinCharges++; if (p.spinCharges < 2) p.spinRechargeTimer = 10.0; }
}
if (p.dashTimer > 0) { p.dashTimer -= dt; p.x += p.dashDir * 420 * dt; }
if (p.jumpTimer > 0) {
p.jumpTimer -= dt;
p.jumpScale = 1.0 + Math.sin((1.0 - p.jumpTimer) * Math.PI) * 0.45;
if (p.jumpTimer <= 0) { p.jumpTimer = 0; p.jumpScale = 1.0; p.isDoingFlip = false; triggerScreenShake(0.18, 4); playSfx(150, 0.15); }
}
if (p.isDoingStunt360) {
p.stuntTimer -= dt;
const prog = Math.min(1, 1 - p.stuntTimer / p.stuntDuration);
p.stuntAngle = p.stuntDir * prog * Math.PI * 2;
p.speed += (p.stuntDecelTarget - p.speed) * (dt / 0.2);
if (p.stuntTimer <= 0) { p.isDoingStunt360 = false; p.stuntAngle = 0; p.speed = p.stuntDecelTarget; }
}
if (p.isDoingFlip) p.flipScaleY = Math.cos((1 - p.jumpTimer) * Math.PI * 2);
if (p.crashed) {
p.crashTimer -= dt;
if (p.crashTimer <= 0) {
p.crashed = false;
p.x = ROAD_LEFT + (isTwoPlayer ? (p.id === 1 ? 40 : ROAD_WIDTH - 64) : ROAD_WIDTH/2 - 12);
p.speed = 80; p.tiltAngle = 0; p.driftVel = 0; p.angularVel = 0;
p.tiltGraceTimer = 0; p.isSpinningOut = false;
p.invulnerableTimer = 2.5; p.fuel = Math.max(p.fuel, 55);
}
return;
}
if (p.speed > highestSpeed) highestSpeed = p.speed;
if (p.speed >= 400) emitSpeedFire(p);
if (p.speed > 0 && !p.isInTurboTunnel) {
const bm = p.carClass.fuelRate;
p.fuel -= (0.016 + (p.speed/410)*0.046) * bm;
if (p.fuel <= 0) { p.fuel = 0; p.speed = Math.max(0, p.speed - 120*dt); }
}
const keyL = isKeyActive(p.keyMap.left);
const keyR = isKeyActive(p.keyMap.right);
const keyLow = isKeyActive(p.keyMap.low);
const keyHigh = isKeyActive(p.keyMap.high);
const keyHB = isKeyActive(p.keyMap.handbrake);
if ((keyHB || p.tiltAngle !== 0 || p.isSpinningOut) && p.speed > 80) {
skidmarks.push({ x: p.x + 3, dist: p.dist, alpha: 0.7 });
skidmarks.push({ x: p.x + p.w - 5, dist: p.dist, alpha: 0.7 });
}
if (keyHB) {
p.speed = 0;
playSfx(150, 0.05, 'sawtooth', 0.01);
if (p.handbrakeCooldown <= 0) {
p.handbrakeCooldown = 1.2;
if (Math.random() < 0.63) {
const add = 0.01 + Math.random() * 0.07;
p.handbrakeDebuffTotal = Math.min(0.35, p.handbrakeDebuffTotal + add);
playSfx(110, 0.35, 'square');
}
}
}
let baseMax = p.carClass.maxSpeed;
if (p.isInTurboTunnel) baseMax += 30;
if (p.shieldTimer > 0) baseMax = Math.min(baseMax, 355);
else if (p.nitroTimer > 0) baseMax = 480 + (p.isInTurboTunnel ? 30 : 0);
baseMax *= p.catchUpBoost;
const epf = 1.0 - p.handbrakeDebuffTotal;
const targetMax = baseMax * epf;
let targetSpeed = 0;
if (p.fuel > 0 || p.isInTurboTunnel) {
if (keyHigh || p.nitroTimer > 0 || p.isInTurboTunnel) targetSpeed = targetMax;
else if (keyLow) targetSpeed = 0;
}
if (p.isSpinningOut) p.speed = Math.max(0, p.speed * (1 - 0.15 * dt));
else if (p.tiltAngle !== 0) { const sd = 320 / 1.65; p.speed = Math.max(0, p.speed - sd * dt); }
else if (!keyHB && !p.isDoingStunt360) {
const accel = (p.nitroTimer > 0 ? 260 : (p.isInTurboTunnel ? 220 : p.carClass.accel)) * dt;
if (p.speed < targetSpeed) p.speed = Math.min(targetSpeed, p.speed + accel);
else if (p.speed > targetSpeed) {
let br = (p.shieldTimer > 0 ? 250 : 170) * dt;
if (keyLow) br = 450 * dt;
p.speed = Math.max(targetSpeed, p.speed - br);
}
}
const rainGrip = stage.rain ? 0.72 : 1.0;
const stab = p.carClass.stability * rainGrip;
if (p.isSpinningOut) {
p.tiltAngle += p.angularVel * dt;
p.x += p.driftVel * 1.5 * dt;
if (p.speed <= 90) {
p.angularVel *= 0.88;
if (Math.abs(p.angularVel) < 1.0) {
p.isSpinningOut = false; p.tiltAngle = 0; p.driftVel = 0; p.angularVel = 0; p.tiltGraceTimer = 0;
}
}
} else if (p.tiltAngle !== 0) {
p.tiltGraceTimer += dt;
p.tiltAngle += p.angularVel * dt;
p.driftVel = p.tiltAngle * 210;
p.x += p.driftVel * dt;
if (p.tiltAngle > 0 && keyL) {
p.angularVel -= 3.8 * stab * dt; p.tiltAngle -= 1.4 * stab * dt;
if (p.tiltAngle <= 0) { p.tiltAngle = 0; p.driftVel = 0; p.angularVel = 0; p.tiltGraceTimer = 0; }
} else if (p.tiltAngle < 0 && keyR) {
p.angularVel += 3.8 * stab * dt; p.tiltAngle += 1.4 * stab * dt;
if (p.tiltAngle >= 0) { p.tiltAngle = 0; p.driftVel = 0; p.angularVel = 0; p.tiltGraceTimer = 0; }
} else if (p.tiltGraceTimer >= 1.8 || Math.abs(p.tiltAngle) > 0.75 * stab) {
p.isSpinningOut = true;
p.angularVel = (p.tiltAngle > 0 ? 1 : -1) * 13.5;
p.driftVel = (p.tiltAngle > 0 ? 1 : -1) * 220;
playSfx(140, 0.35, 'sawtooth');
}
} else {
const sf = p.rainbowOilTimer > 0 ? 0.45 : 1.0;
const ss = p.carClass.steer * sf * dt;
if (keyL) p.x -= ss;
if (keyR) p.x += ss;
}
if (p.x <= currentRoadLeft + 6 || p.x + p.w >= currentRoadLeft + currentRoadWidth - 6) { crashPlayerInstant(p); return; }
p.dist += p.speed * dt;
const isCounter = p.x < currentRoadLeft + currentRoadWidth / 3;
const mult = isCounter ? 0.48 : 0.16;
p.score += Math.floor((p.speed * dt) * mult);
});
updateEngineSound(highestSpeed);
const worldSpeed = Math.max(50, highestSpeed);
const minP = players.reduce((m,p)=>Math.min(m,p.dist), Infinity);
const maxP = players.reduce((m,p)=>Math.max(m,p.dist), 0);
// ===== Traffic =====
for (let i = traffic.length - 1; i >= 0; i--) {
const car = traffic[i];
car.dist += car.speed * dt;
if (car.angryRacer && !car.angryTriggered && car.dist < maxP - 30) {
car.angryTriggered = true; car.speed = 400; car.color = '#ff0033';
playSfx(480, 0.3, 'sawtooth');
}
if (car.cutByPlayer) {
car.cutTimer -= dt;
if (car.cutTimer <= 0) {
car.cutByPlayer = false;
car.isPurpleAgressor = true;
car.isRevengeAI = true;
car.color = '#ff69b4';
car.aiTimer = 0;
car.aiTactic = 'PURSUE';
car.sideDir = Math.random() > 0.5 ? 1 : -1;
car.speed = Math.max(420, worldSpeed * 1.1);
car.dist = maxP + 150;
car.tiltAngle = 0; car.angularVel = 0; car.driftVel = 0; car.isSpinning = false;
car.hbPhase = 0; car.hbTimer = 0;
playSfx(550, 0.5, 'sawtooth');
}
}
// ===== УМНЫЙ ИИ РОЗОВОЙ МАШИНЫ =====
if (car.isPurpleAgressor && !car.isSpinning && car.tiltAngle === 0) {
if (car.aiTimer === undefined) car.aiTimer = 0;
if (car.aiTactic === undefined) car.aiTactic = 'PURSUE';
if (car.sideDir === undefined) car.sideDir = Math.random() > 0.5 ? 1 : -1;
if (car.hbPhase === undefined) car.hbPhase = 0;
if (car.hbTimer === undefined) car.hbTimer = 0;
car.aiTimer -= dt;
const smart = car.isRevengeAI === true;
let target = null;
let bestDist = Infinity;
for (const p of players) {
if (p.crashed) continue;
const d = Math.abs(car.dist - p.dist);
if (d < bestDist) { bestDist = d; target = p; }
}
if (target) {
const distDiff = target.dist - car.dist;
const dx = target.x - car.x;
const closeRange = Math.abs(distDiff) < (smart ? 260 : 180) / DIST_PX;
if (car.aiTimer <= 0) {
car.aiTimer = (smart ? 0.8 : 1.5) + Math.random() * (smart ? 0.8 : 1.5);
// Сброс фазы ручника при смене тактики
car.hbPhase = 0; car.hbTimer = 0;
const r = Math.random();
if (closeRange) {
if (smart) {
// Умный AI умеет дёргать ручник для тарана
if (r < 0.30) car.aiTactic = 'HANDBRAKE_RAM';
else if (r < 0.55) car.aiTactic = 'RAM';
else if (r < 0.80) car.aiTactic = 'SIDE';
else car.aiTactic = 'BLOCK';
} else {
if (r < 0.35) car.aiTactic = 'RAM';
else if (r < 0.65) car.aiTactic = 'SIDE';
else car.aiTactic = 'BLOCK';
}
} else if (distDiff > 0) {
car.aiTactic = 'PURSUE';
} else {
car.aiTactic = smart ? 'PURSUE' : 'WAIT';
}
}
switch (car.aiTactic) {
case 'PURSUE':
car.speed = (smart ? 455 : 420) + Math.random() * 40;
car.x += Math.sign(dx) * (smart ? 160 : 130) * dt;
break;
case 'WAIT':
car.speed = Math.max(90, target.speed * (smart ? 0.9 : 0.75));
car.x += Math.sign(dx) * 100 * dt;
break;
case 'RAM':
car.speed = target.speed * (smart ? 1.05 : 0.98);
if (Math.abs(dx) > (smart ? 3 : 6)) {
car.x += Math.sign(dx) * (smart ? 240 : 200) * dt;
} else {
if (distDiff > 25 / DIST_PX) car.speed = target.speed * (smart ? 1.30 : 1.20);
else if (distDiff < -25 / DIST_PX) car.speed = target.speed * (smart ? 0.70 : 0.80);
}
break;
// === НОВАЯ ТАКТИКА: дёрнуть ручник и протаранить ===
case 'HANDBRAKE_RAM':
if (car.hbPhase === 0) {
// Фаза 0: резко сбрасываем скорость как ручником
car.speed = Math.max(20, car.speed - 1400 * dt);
car.hbTimer += dt;
// Дым из-под колёс
if (Math.random() < 0.6) {
particles.push({
x: car.x + (Math.random()-0.5) * car.w,
y: PLAYER_SCREEN_Y - (car.dist - players[0].dist) * DIST_PX + car.h * 0.5,
vx: (Math.random()-0.5)*3, vy: 1.5 + Math.random()*3,
dist: car.dist, size: 3 + Math.random()*3, life: 14,
color: ['#666','#888','#aaa'][Math.floor(Math.random()*3)]
});
}
if (car.hbTimer > 0.5 || car.speed <= 30) {
car.hbPhase = 1;
car.hbTimer = 0;
car.ramTargetSpeed = target.speed * 1.45 + 60;
playSfx(200, 0.2, 'sawtooth');
}
} else {
// Фаза 1: стремительный рывок в игрока
car.speed = Math.min(car.ramTargetSpeed, car.speed + 1200 * dt);
if (Math.abs(dx) > 3) car.x += Math.sign(dx) * 340 * dt;
car.hbTimer += dt;
if (car.hbTimer > 1.2) {
car.hbPhase = 0; car.hbTimer = 0;
}
}
break;
case 'SIDE':
car.speed = target.speed * (smart ? 1.02 : 1.0);
if (Math.abs(distDiff) < 50 / DIST_PX) {
car.x += car.sideDir * (smart ? 260 : 220) * dt;
if (car.sideDir > 0 && car.x > target.x + 20) car.sideDir = -1;
if (car.sideDir < 0 && car.x < target.x - 20) car.sideDir = 1;
} else {
car.x += Math.sign(dx) * 150 * dt;
}
break;
case 'BLOCK':
car.speed = target.speed * (smart ? 0.95 : 0.85);
car.x += Math.sign(dx) * (smart ? 180 : 120) * dt;
break;
}
if (car.x < currentRoadLeft + 6) car.x = currentRoadLeft + 6;
if (car.x + car.w > currentRoadLeft + currentRoadWidth - 6) car.x = currentRoadLeft + currentRoadWidth - 6 - car.w;
} else {
car.speed = 130 * STAGES[currentLevel].trafficSpeed;
}
}
if (car.isRevengeReady && !car.revengeActive) {
car.revengeTimer -= dt;
if (car.revengeTimer <= 0) {
car.revengeActive = true; car.color = '#ff1493'; car.type = 'revenge';
car.revengeTactic = Math.random() < 0.5 ? 'SIDE_AMBUSH' : 'BLOCK';
car.ambushTriggered = false; playSfx(550, 0.35, 'sawtooth');
}
}
if (car.revengeActive && car.revengeAttempts > 0) {
const tp = players[0];
if (tp && !tp.crashed) {
if (car.revengeTactic === 'SIDE_AMBUSH') {
const dY = Math.abs(car.dist - tp.dist) * DIST_PX;
if (dY < 55) car.ambushTriggered = true;
if (car.ambushTriggered) {
const sd = car.x < tp.x ? 1 : -1;
car.x += sd * 280 * dt;
}
car.dist = tp.dist + (car.ambushTriggered ? 45/DIST_PX : 100/DIST_PX);
} else {
if (Math.abs(car.dist - tp.dist) < 12/DIST_PX) car.speed = worldSpeed * 0.95;
else car.speed = worldSpeed * 1.05;
const sd = car.x < tp.x ? 1 : -1;
car.x += sd * 125 * dt;
}
}
}
if (car.isSpinning) {
car.tiltAngle += car.angularVel * dt;
car.x += car.driftVel * dt;
car.speed = Math.max(30, car.speed - 120 * dt);
if (car.x <= currentRoadLeft + 4 || car.x + car.w >= currentRoadLeft + currentRoadWidth - 4) {
createExplosion(car.x + car.w/2, PLAYER_SCREEN_Y, car.dist);
traffic.splice(i, 1); continue;
}
} else if (car.tiltAngle !== 0) {
car.tiltAngle += car.angularVel * dt;
car.driftVel = car.tiltAngle * 170;
car.x += car.driftVel * dt;
if (car.canRecover) {
if (car.tiltAngle > 0) { car.angularVel -= 3.2*dt; car.tiltAngle -= 1.3*dt; if (car.tiltAngle <= 0) finishRecovery(car); }
else if (car.tiltAngle < 0) { car.angularVel += 3.2*dt; car.tiltAngle -= 1.3*dt; if (car.tiltAngle >= 0) finishRecovery(car); }
}
if (Math.abs(car.tiltAngle) > 0.7) { car.isSpinning = true; car.angularVel = (car.tiltAngle > 0 ? 1 : -1) * 11.0; }
if (car.x <= currentRoadLeft + 4 || car.x + car.w >= currentRoadLeft + currentRoadWidth - 4) {
createExplosion(car.x + car.w/2, PLAYER_SCREEN_Y, car.dist);
traffic.splice(i, 1); continue;
}
} else if (car.type === 'shifter') {
car.x += car.dir * 55 * dt;
if (car.x < currentRoadLeft + 20) { car.x = currentRoadLeft + 20; car.dir = 1; }
if (car.x > currentRoadLeft + currentRoadWidth - car.w - 20) { car.x = currentRoadLeft + currentRoadWidth - car.w - 20; car.dir = -1; }
}
players.forEach(p => {
if (!p.crashed && p.jumpTimer === 0 && collidesPlayerObj(p, car)) {
if ((p.dashTimer > 0 || p.invulnerableTimer > 0) && car.type !== 'truck') {
createExplosion(car.x + car.w/2, PLAYER_SCREEN_Y, car.dist);
p.score += 800; traffic.splice(i, 1); return;
}
if (p.invulnerableTimer > 0 && car.type === 'truck') {
createExplosion(car.x + car.w/2, PLAYER_SCREEN_Y, car.dist);
traffic.splice(i, 1); return;
}
if (car.revengeActive && car.revengeAttempts > 0) {
car.revengeAttempts--; car.ambushTriggered = false;
if (p.speed < 120) { p.speed = Math.max(40, p.speed - 30); car.x += (car.x > p.x ? 45 : -45); playSfx(200, 0.2); }
else {
p.speed = Math.max(40, p.speed - 90);
p.tiltAngle = (p.x < car.x ? -1 : 1) * 0.35;
p.angularVel = (p.x < car.x ? -1 : 1) * 1.3;
p.tiltGraceTimer = 0; playSfx(140, 0.3, 'sawtooth');
}
if (car.revengeAttempts <= 0) { car.revengeActive = false; car.color = '#555'; }
return;
}
if (car.type === 'truck' || car.type === 'counter') { crashPlayer(p); return; }
if (p.shieldTimer > 0) {
createExplosion(car.x + car.w/2, PLAYER_SCREEN_Y, car.dist);
p.score += 600; traffic.splice(i, 1);
} else {
const isPurple = car.isPurpleAgressor === true;
const isSmartPink = car.isRevengeAI === true;
const isHB = car.aiTactic === 'HANDBRAKE_RAM';
p.speed = Math.max(50, p.speed - (isHB ? 150 : (isSmartPink ? 130 : (isPurple ? 110 : 75))));
const hd = p.x < car.x ? -1 : 1;
const bumpStrength = isHB ? 0.45 : (isSmartPink ? 0.38 : (isPurple ? 0.32 : 0.22));
if (!p.isSpinningOut) { p.tiltAngle = hd * (bumpStrength / p.carClass.stability); p.angularVel = hd * (isHB ? 1.7 : (isSmartPink ? 1.45 : (isPurple ? 1.25 : 0.95))); p.tiltGraceTimer = 0; }
if (Math.random() < 0.65 && !car.isSpinning) {
car.tiltAngle = -hd * 0.24; car.angularVel = -hd * 0.95;
car.canRecover = Math.random() < 0.60;
if (Math.random() < 0.35 && !car.isPurpleAgressor && !car.revengeActive && !car.cutByPlayer && car.type !== 'truck' && car.type !== 'counter') {
car.cutByPlayer = true;
car.cutTimer = 5 + Math.random() * 5;
playSfx(680, 0.4, 'sawtooth');
}
} else car.x += (hd < 0 ? 25 : -25);
// После ручника-тарана сбрасываем фазу, чтобы не залипал
if (isHB) { car.hbPhase = 0; car.hbTimer = 0; }
playSfx(160, 0.25, 'sawtooth');
}
}
});
}
for (let i = traffic.length - 1; i >= 0; i--) {
const c = traffic[i];
if (c.dist < minP - 500 || c.dist > maxP + 2500) traffic.splice(i, 1);
}
// ===== Obstacles =====
for (let i = obstacles.length - 1; i >= 0; i--) {
const ob = obstacles[i];
if (ob.type === 'oil_rainbow') ob.pulse += dt * 3;
players.forEach(p => {
if (!p.crashed && p.jumpTimer === 0 && p.invulnerableTimer <= 0 && collidesPlayerObj(p, ob)) {
if (ob.type === 'oil_black') {
if (!p.isSpinningOut && p.tiltAngle === 0) {
const d = Math.random() > 0.5 ? 1 : -1;
p.tiltAngle = d * (0.24 / p.carClass.stability);
p.angularVel = d * 0.95; p.tiltGraceTimer = 0;
playSfx(210, 0.2, 'sawtooth');
}
} else if (ob.type === 'oil_rainbow') { p.rainbowOilTimer = 3.5; playSfx(380, 0.25, 'triangle'); }
else if (ob.type === 'rock') {
if (p.shieldTimer > 0) { createExplosion(ob.x + ob.w/2, PLAYER_SCREEN_Y, ob.dist); obstacles.splice(i, 1); }
else crashPlayer(p);
}
}
});
}
for (let i = obstacles.length - 1; i >= 0; i--) {
const ob = obstacles[i];
if (ob.dist < minP - 500 || ob.dist > maxP + 2500) obstacles.splice(i, 1);
}
// ===== Pickups =====
for (let i = pickups.length - 1; i >= 0; i--) {
const pk = pickups[i];
pk.pulse += dt * 5;
players.forEach(p => {
if (!p.crashed && p.jumpTimer === 0 && collidesPlayerObj(p, pk)) {
if (pk.type === 'FUEL') { p.fuel = Math.min(100, p.fuel + 45); p.score += 800; playSfx(650, 0.15); }
else if (pk.type === 'TIME') { timeLeft = Math.min(200, timeLeft + 12); p.score += 1000; playSfx(800, 0.18); }
else if (pk.type === 'NITRO') { p.nitroTimer = 4.5; p.speed = 460; p.score += 1500; playSfx(950, 0.3); }
else if (pk.type === 'SHIELD') { p.shieldTimer = 8.0; p.score += 1200; playSfx(880, 0.25); }
pickups.splice(i, 1);
}
});
}
for (let i = pickups.length - 1; i >= 0; i--) {
const pk = pickups[i];
if (pk.dist < minP - 500 || pk.dist > maxP + 2500) pickups.splice(i, 1);
}
// ===== Ramps =====
for (let i = ramps.length - 1; i >= 0; i--) {
const rmp = ramps[i];
players.forEach(p => {
if (!p.crashed && p.jumpTimer === 0 && collidesPlayerObj(p, rmp)) {
p.jumpTimer = 1.0; playSfx(480, 0.3, 'sine');
}
});
if (rmp.dist < minP - 500 || rmp.dist > maxP + 2500) ramps.splice(i, 1);
}
// ===== Cops =====
for (let i = cops.length - 1; i >= 0; i--) {
const cop = cops[i];
cop.dist += cop.speed * dt;
cop.sirenTick++;
if (cop.sirenTick % 12 === 0) playSfx(cop.sirenTick % 24 === 0 ? 820 : 640, 0.1, 'square');
const tp = players[0];
if (tp && !tp.crashed) {
const pushLeft = tp.x < currentRoadLeft + currentRoadWidth / 2;
const desiredX = pushLeft ? tp.x + 20 : tp.x - 20;
cop.x += (desiredX - cop.x) * 3.5 * dt;
if (collidesPlayerObj(tp, cop) && tp.jumpTimer === 0) {
if (tp.dashTimer > 0 || tp.invulnerableTimer > 0) {
createExplosion(cop.x + cop.w/2, PLAYER_SCREEN_Y, cop.dist); cops.splice(i, 1); continue;
}
tp.x += (pushLeft ? -1 : 1) * 110 * dt;
tp.speed = Math.max(50, tp.speed - 80 * dt);
playSfx(160, 0.15, 'sawtooth');
if (tp.shieldTimer > 0) { createExplosion(cop.x + cop.w/2, PLAYER_SCREEN_Y, cop.dist); cops.splice(i, 1); continue; }
}
}
if (cop.dist < minP - 500 || cop.dist > maxP + 2500) cops.splice(i, 1);
}
// ===== Pit vans =====
for (let i = pitVans.length - 1; i >= 0; i--) {
const van = pitVans[i];
van.dist += van.speed * dt;
van.arrowTick++;
players.forEach(p => {
if (!p.crashed && p.jumpTimer === 0 && collidesPlayerObj(p, van)) {
p.handbrakeDebuffTotal = 0; p.fuel = 100; p.speed = Math.max(p.speed, 280);
playSfx(950, 0.35, 'triangle'); playSfx(1200, 0.35, 'sine');
createPitStopSparkles(van.x + van.w/2, PLAYER_SCREEN_Y, van.dist);
pitVans.splice(i, 1);
}
});
if (van.dist < minP - 500 || van.dist > maxP + 2500) pitVans.splice(i, 1);
}
// ===== Tunnels =====
for (let i = tunnels.length - 1; i >= 0; i--) {
const t = tunnels[i];
t.neonTick += dt * 4;
if (t.dist + t.height / DIST_PX < minP - 500 || t.dist > maxP + 2500) tunnels.splice(i, 1);
}
// ===== Road cones =====
for (let i = roadConeProps.length - 1; i >= 0; i--) {
const c = roadConeProps[i];
if (c.dist < minP - 500 || c.dist > maxP + 2500) roadConeProps.splice(i, 1);
}
// ===== Skidmarks =====
for (let i = skidmarks.length - 1; i >= 0; i--) {
const s = skidmarks[i];
s.alpha -= dt * 0.45;
if (s.alpha <= 0 || s.dist < minP - 500) skidmarks.splice(i, 1);
}
// ===== Boss =====
if (activeBoss) {
const b = activeBoss;
if (b.type === 'tanker') {
if (b.dist - maxP > 100) b.dist += (b.speed - worldSpeed) * dt;
else b.dist += (b.speed - worldSpeed * 0.9) * dt;
if (b.dist - maxP > 200) b.dist -= 40 * dt;
b.actionTick++;
if (b.actionTick % 45 === 0) {
obstacles.push({ x: b.x + 10 + Math.random()*(b.w-30), dist: b.dist + 20, w: 48, h: 30, type: 'oil_black' });
playSfx(140, 0.2, 'sawtooth');
}
players.forEach(p => {
if (!p.crashed && p.jumpTimer === 0 && collidesPlayerObj(p, b)) {
if (p.dashTimer > 0) {
b.hp--; createExplosion(p.x + p.w/2, PLAYER_SCREEN_Y, b.dist); playSfx(280, 0.2);
if (b.hp <= 0) { createExplosion(b.x + b.w/2, PLAYER_SCREEN_Y, b.dist); p.score += 5000; activeBoss = null; }
} else if (p.invulnerableTimer <= 0) crashPlayer(p);
}
});
} else if (b.type === 'heli') {
b.rotorAngle += dt * 35;
b.dist += (b.speed - worldSpeed) * dt;
if (b.dist - maxP > 250) b.dist -= 30 * dt;
const tp = players[0];
b.x += (tp.x - b.x) * 1.5 * dt;
b.actionTick++;
if (b.actionTick % 95 === 0) {
const dropX = b.x + b.w/2;
playSfx(380, 0.3, 'sawtooth');
setTimeout(() => { createExplosion(dropX, PLAYER_SCREEN_Y - 40, tp.dist); }, 600);
}
}
}
spawnWorld();
const leader = players.reduce((a,b)=>a.dist > b.dist ? a : b);
if (leader.dist >= stage.distance) {
if (currentLevel + 1 < STAGES.length) {
gameState = 'STAGE_CLEAR'; playSfx(600, 0.4);
} else {
saveHighScore(players[0].score, stageTimePassed);
resetDeaths(); gameState = 'VICTORY'; playSfx(750, 0.6);
}
return;
}
updateParticles();
}
function finishRecovery(car) {
car.tiltAngle = 0; car.angularVel = 0; car.driftVel = 0; car.canRecover = false;
car.isRevengeReady = true; car.speed += 95;
car.revengeTimer = 5 + Math.random() * 10; car.revengeAttempts = 3;
}
function crashPlayer(p) {
if (p.crashed || p.invulnerableTimer > 0) return;
p.crashed = true; p.crashTimer = 1.1; p.speed = 0;
p.jumpTimer = 0; p.jumpScale = 1.0; p.nitroTimer = 0;
p.isDoingStunt360 = false; p.stuntAngle = 0; p.isDoingFlip = false; p.rainbowOilTimer = 0;
triggerScreenShake(0.4, 7);
createExplosion(p.x + p.w/2, PLAYER_SCREEN_Y, p.dist);
}
function crashPlayerInstant(p) {
if (p.crashed) return;
p.crashed = true; p.crashTimer = 1.1; p.speed = 0;
p.jumpTimer = 0; p.jumpScale = 1.0; p.nitroTimer = 0;
p.isDoingStunt360 = false; p.stuntAngle = 0; p.isDoingFlip = false; p.rainbowOilTimer = 0;
p.invulnerableTimer = 0; p.shieldTimer = 0;
triggerScreenShake(0.5, 9);
createExplosion(p.x + p.w/2, PLAYER_SCREEN_Y, p.dist);
playSfx(60, 0.6, 'sawtooth');
}
// ===== DRAW =====
function draw() {
ctx.fillStyle = '#08080c';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (isTwoPlayer && players.length === 2) {
drawViewport(players[0], 0, 310);
drawViewport(players[1], 310, 310);
ctx.fillStyle = '#000'; ctx.fillRect(308, 0, 4, 660);
ctx.strokeStyle = '#444'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(310, 0); ctx.lineTo(310, 660); ctx.stroke();
ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,71,87,0.85)'; ctx.fillText('P1', 155, 14);
ctx.fillStyle = 'rgba(0,229,255,0.7)'; ctx.fillText('P2', 465, 14);
ctx.textAlign = 'left';
} else {
drawViewport(players[0], 0, 620);
}
if (gameState === 'PRESS_START') drawPressStartScreen();
else if (gameState === 'MENU') drawMenuScreen();
else if (gameState === 'STAGE_CLEAR') drawOverlay('ЭТАП ПРОЙДЕН!', 'ЧЕКПОИНТ СОХРАНЁН! [ENTER]', '#2ecc71');
else if (gameState === 'GAMEOVER') drawGameOverMenu();
else if (gameState === 'ALL_LIVES_LOST') drawAllLivesLostScreen();
else if (gameState === 'VICTORY') drawOverlay('ЧЕМПИОН ВСЕХ ТРАСС!', 'ИГРА ПОЛНОСТЬЮ ПРОЙДЕНА! [ENTER]', '#f1c40f');
}
function drawViewport(player, vx, vw) {
if (!player) return;
ctx.save();
ctx.beginPath();
ctx.rect(vx, 0, vw, 660);
ctx.clip();
ctx.translate(vx, 0);
if (screenShakeTimer > 0) {
const sx = (Math.random()-0.5) * screenShakeMagnitude;
const sy = (Math.random()-0.5) * screenShakeMagnitude;
ctx.translate(sx, sy);
}
const stage = STAGES[currentLevel] || STAGES[0];
const pd = player.dist;
const sy = (d) => PLAYER_SCREEN_Y - (d - pd) * DIST_PX;
const myScroll = (pd * DIST_PX) % 60;
ctx.fillStyle = stage.grassColor;
ctx.fillRect(0, 0, vw, 660);
ctx.fillStyle = stage.night ? '#1e2430' : '#2c3e50';
ctx.fillRect(currentRoadLeft, 0, currentRoadWidth, 660);
for (let sm of skidmarks) {
const smY = sy(sm.dist);
if (smY > -20 && smY < 700) {
ctx.fillStyle = `rgba(10,10,15,${sm.alpha})`;
ctx.fillRect(sm.x, smY, 4, 12);
}
}
const curbStep = 30;
for (let y = -curbStep; y < 660 + curbStep; y += curbStep) {
const isRed = ((y + myScroll) % (curbStep*2)) < curbStep;
ctx.fillStyle = isRed ? '#e74c3c' : '#ecf0f1';
ctx.fillRect(currentRoadLeft - 12, y + (myScroll % curbStep), 12, curbStep);
ctx.fillRect(currentRoadLeft + currentRoadWidth, y + (myScroll % curbStep), 12, curbStep);
}
const laneW = currentRoadWidth / 3;
ctx.fillStyle = '#ffffff';
for (let y = -40; y < 660 + 40; y += 45) {
ctx.fillRect(currentRoadLeft + laneW - 2, y + (myScroll % 45), 4, 24);
ctx.fillRect(currentRoadLeft + laneW*2 - 2, y + (myScroll % 45), 4, 24);
}
for (let c of roadConeProps) {
const cY = sy(c.dist);
if (cY > -30 && cY < 700) {
ctx.fillStyle = '#e67e22';
ctx.beginPath();
ctx.moveTo(c.x, cY + 14); ctx.lineTo(c.x + 6, cY); ctx.lineTo(c.x + 12, cY + 14);
ctx.closePath(); ctx.fill();
}
}
for (let t of tunnels) {
const tY = sy(t.dist);
const tH = t.height;
if (tY + tH > -100 && tY < 800) {
ctx.fillStyle = 'rgba(10,15,30,0.88)';
ctx.fillRect(currentRoadLeft, tY, currentRoadWidth, tH);
const nc = (Math.floor(t.neonTick) % 2 === 0) ? '#00e5ff' : '#d500f9';
ctx.strokeStyle = nc; ctx.lineWidth = 4;
ctx.strokeRect(currentRoadLeft, tY, currentRoadWidth, tH);
for (let ay = tY; ay < tY + tH; ay += 75) {
if (ay > -50 && ay < 700) {
ctx.strokeStyle = 'rgba(0,229,255,0.35)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(currentRoadLeft, ay); ctx.lineTo(currentRoadLeft + currentRoadWidth, ay); ctx.stroke();
}
}
ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center';
ctx.fillText('>> TURBO TUNNEL (+30) >>', vw/2, tY + 20);
ctx.textAlign = 'left';
}
}
for (let rmp of ramps) {
const y = sy(rmp.dist);
if (y > -60 && y < 700) {
ctx.fillStyle = '#f1c40f';
ctx.beginPath();
ctx.moveTo(rmp.x, y + rmp.h);
ctx.lineTo(rmp.x + rmp.w/2, y);
ctx.lineTo(rmp.x + rmp.w, y + rmp.h);
ctx.lineTo(rmp.x + rmp.w/2, y + rmp.h * 0.5);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#d35400'; ctx.lineWidth = 2; ctx.stroke();
}
}
for (let ob of obstacles) {
const y = sy(ob.dist);
if (y > -60 && y < 700) {
if (ob.type === 'oil_black') {
ctx.fillStyle = '#111';
ctx.beginPath(); ctx.ellipse(ob.x + ob.w/2, y + ob.h/2, ob.w/2, ob.h/2, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.fill();
} else if (ob.type === 'oil_rainbow') {
const g = ctx.createLinearGradient(ob.x, y, ob.x + ob.w, y + ob.h);
g.addColorStop(0, '#ff007f'); g.addColorStop(0.33, '#00ffff');
g.addColorStop(0.66, '#ffea00'); g.addColorStop(1, '#76ff03');
ctx.fillStyle = g;
ctx.beginPath(); ctx.ellipse(ob.x + ob.w/2, y + ob.h/2, ob.w/2, ob.h/2, 0, 0, Math.PI*2); ctx.fill();
} else if (ob.type === 'rock') {
ctx.fillStyle = '#7f8c8d'; ctx.fillRect(ob.x, y, ob.w, ob.h);
ctx.fillStyle = '#bdc3c7'; ctx.fillRect(ob.x + 3, y + 3, ob.w - 6, ob.h - 6);
}
}
}
if (activeBoss) {
const y = sy(activeBoss.dist);
if (y > -400 && y < 800) {
ctx.save();
ctx.translate(activeBoss.x, y);
if (activeBoss.type === 'tanker') {
ctx.fillStyle = '#2c3e50'; ctx.fillRect(0, 0, activeBoss.w, activeBoss.h);
ctx.fillStyle = '#c0392b'; ctx.fillRect(6, 20, activeBoss.w - 12, activeBoss.h - 35);
ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 11px monospace';
ctx.fillText(`HP: ${activeBoss.hp}`, 12, -8);
} else if (activeBoss.type === 'heli') {
ctx.fillStyle = '#2d3436'; ctx.fillRect(0, 0, activeBoss.w, activeBoss.h);
ctx.save(); ctx.translate(activeBoss.w/2, activeBoss.h/2); ctx.rotate(activeBoss.rotorAngle);
ctx.fillStyle = '#636e72'; ctx.fillRect(-45, -3, 90, 6); ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.beginPath();
ctx.moveTo(activeBoss.w/2, activeBoss.h);
ctx.lineTo(-40, activeBoss.h + 260);
ctx.lineTo(activeBoss.w + 40, activeBoss.h + 260);
ctx.closePath(); ctx.fill();
}
ctx.restore();
}
}
for (let van of pitVans) {
const y = sy(van.dist);
if (y > -120 && y < 700) {
ctx.save();
ctx.translate(van.x + van.w/2, y + van.h/2);
ctx.fillStyle = '#00b894'; ctx.fillRect(-van.w/2, -van.h/2, van.w, van.h);
ctx.fillStyle = '#27ae60'; ctx.fillRect(-van.w/2 + 3, -van.h/2 + 10, van.w - 6, van.h - 25);
ctx.fillStyle = '#111'; ctx.fillRect(-van.w/2 + 2, -van.h/2 + 4, van.w - 4, 8);
ctx.fillRect(-van.w/2 + 4, van.h/2 - 12, van.w - 8, 12);
const ab = Math.floor(van.arrowTick/6) % 2 === 0;
ctx.fillStyle = ab ? '#00ff66' : '#fff';
ctx.beginPath(); ctx.moveTo(0, van.h/2 - 2); ctx.lineTo(-6, van.h/2 - 9); ctx.lineTo(6, van.h/2 - 9);
ctx.closePath(); ctx.fill();
ctx.restore();
}
}
for (let pk of pickups) {
const y = sy(pk.dist);
if (y > -40 && y < 700) {
const scale = 1 + Math.sin(pk.pulse) * 0.12;
ctx.save(); ctx.translate(pk.x + pk.w/2, y + pk.h/2); ctx.scale(scale, scale);
if (pk.type === 'FUEL') { ctx.fillStyle = '#27ae60'; ctx.fillRect(-pk.w/2, -pk.h/2, pk.w, pk.h); ctx.fillStyle='#fff'; ctx.fillText('F',-4,4); }
else if (pk.type === 'TIME') { ctx.fillStyle = '#f39c12'; ctx.fillRect(-pk.w/2, -pk.h/2, pk.w, pk.h); ctx.fillStyle='#fff'; ctx.fillText('T',-4,4); }
else if (pk.type === 'NITRO') { ctx.fillStyle = '#9b59b6'; ctx.fillRect(-pk.w/2, -pk.h/2, pk.w, pk.h); ctx.fillStyle='#fff'; ctx.fillText('N',-4,4); }
else if (pk.type === 'SHIELD') { ctx.fillStyle = '#2980b9'; ctx.fillRect(-pk.w/2, -pk.h/2, pk.w, pk.h); ctx.fillStyle='#fff'; ctx.fillText('S',-4,4); }
ctx.restore();
}
}
for (let car of traffic) {
const y = sy(car.dist);
if (y > -100 && y < 700) {
ctx.save();
ctx.translate(car.x + car.w/2, y + car.h/2);
if (car.tiltAngle) ctx.rotate(car.tiltAngle);
if (car.revengeActive) { ctx.strokeStyle = '#ff007f'; ctx.lineWidth = 2; ctx.strokeRect(-car.w/2 - 3, -car.h/2 - 3, car.w + 6, car.h + 6); }
// Розовая обводка для умных
if (car.isRevengeAI) {
ctx.strokeStyle = '#ff1493';
ctx.lineWidth = 3;
ctx.strokeRect(-car.w/2 - 5, -car.h/2 - 5, car.w + 10, car.h + 10);
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 1;
ctx.strokeRect(-car.w/2 - 7, -car.h/2 - 7, car.w + 14, car.h + 14);
} else if (car.isPurpleAgressor) {
ctx.strokeStyle = 'rgba(255,105,180,0.65)';
ctx.lineWidth = 2;
ctx.strokeRect(-car.w/2 - 4, -car.h/2 - 4, car.w + 8, car.h + 8);
}
// Жёлтая пунктирная обводка "порезан"
if (car.cutByPlayer) {
ctx.strokeStyle = 'rgba(255,235,59,0.9)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.strokeRect(-car.w/2 - 6, -car.h/2 - 6, car.w + 12, car.h + 12);
ctx.setLineDash([]);
}
// Красная обводка во время ручника-тарана
if (car.aiTactic === 'HANDBRAKE_RAM') {
ctx.strokeStyle = 'rgba(255,60,60,0.9)';
ctx.lineWidth = 2;
ctx.strokeRect(-car.w/2 - 6, -car.h/2 - 6, car.w + 12, car.h + 12);
}
ctx.fillStyle = car.color; ctx.fillRect(-car.w/2, -car.h/2, car.w, car.h);
ctx.fillStyle = '#111';
ctx.fillRect(-car.w/2 - 2, -car.h/2 + 4, 2, 10); ctx.fillRect(car.w/2, -car.h/2 + 4, 2, 10);
ctx.fillRect(-car.w/2 - 2, car.h/2 - 14, 2, 10); ctx.fillRect(car.w/2, car.h/2 - 14, 2, 10);
if (car.type === 'truck') {
ctx.fillStyle = '#111'; ctx.fillRect(-car.w/2 + 2, -car.h/2 + 6, car.w - 4, 10);
} else {
ctx.fillStyle = '#222';
ctx.fillRect(-car.w/2 + 3, -car.h/2 + 9, car.w - 6, 8);
ctx.fillRect(-car.w/2 + 4, car.h/2 - 11, car.w - 8, 5);
}
ctx.restore();
}
}
for (let cop of cops) {
const y = sy(cop.dist);
if (y > -100 && y < 700) {
ctx.save(); ctx.translate(cop.x + cop.w/2, y + cop.h/2);
ctx.fillStyle = '#111'; ctx.fillRect(-cop.w/2, -cop.h/2, cop.w, cop.h);
ctx.fillStyle = '#f5f6fa'; ctx.fillRect(-cop.w/2, -cop.h/4, cop.w, cop.h/2);
const red = (Math.floor(cop.sirenTick/6) % 2 === 0);
ctx.fillStyle = red ? '#e74c3c' : '#0984e3';
ctx.fillRect(-cop.w/3, -4, cop.w * 0.66, 8);
ctx.restore();
}
}
for (let pt of particles) {
const py = pt.dist !== undefined ? sy(pt.dist) + (pt.y - PLAYER_SCREEN_Y) : pt.y;
if (py > -20 && py < 700) {
ctx.fillStyle = pt.color;
ctx.fillRect(pt.x, py, pt.size, pt.size);
}
}
if (isTwoPlayer && players.length === 2) {
const other = (player.id === 1) ? players[1] : players[0];
if (other && !other.crashed) {
const otherY = sy(other.dist);
if (otherY > -200 && otherY < 900) {
drawPlayerAt(other, otherY, { ghost: true, label: `P${other.id}` });
}
}
}
if (!player.crashed) {
drawPlayerAt(player, PLAYER_SCREEN_Y, { ghost: false, label: isTwoPlayer ? `P${player.id}` : null });
}
if (stage.night) drawNightEffectForViewport(player, vw);
if (stage.rain) {
ctx.strokeStyle = 'rgba(173,216,230,0.4)'; ctx.lineWidth = 1.5;
ctx.beginPath();
for (let drop of raindrops) {
ctx.moveTo(drop.x - vx, drop.y);
ctx.lineTo(drop.x - vx - 4, drop.y + drop.len);
}
ctx.stroke();
}
drawViewportHUD(player, vw);
if (isTwoPlayer && player.id === 2) drawP2KeyIndicator(vw);
ctx.restore();
}
function drawP2KeyIndicator(vw) {
const keys = [
{ code: 'KeyI', label: 'I', alt: ['ArrowUp', 'Numpad8', 'Digit8'] },
{ code: 'KeyJ', label: 'J', alt: ['ArrowLeft', 'Numpad4', 'Digit4', 'Home'] },
{ code: 'KeyK', label: 'K', alt: ['ArrowDown', 'Numpad2', 'Numpad5', 'Digit2', 'Digit5'] },
{ code: 'KeyL', label: 'L', alt: ['ArrowRight', 'Numpad6', 'Digit6', 'End'] }
];
const bx = vw - 52;
const by = 660 - 60;
ctx.save();
ctx.font = 'bold 8px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(bx - 4, by - 12, 50, 52);
ctx.strokeStyle = '#2d3446'; ctx.lineWidth = 1;
ctx.strokeRect(bx - 4, by - 12, 50, 52);
ctx.fillStyle = '#888';
ctx.fillText('P2 КЕЙС', bx, by - 3);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const on = activeKeys[k.code] || k.alt.some(c => activeKeys[c]);
const kx = bx + (i % 2) * 22;
const ky = by + 10 + Math.floor(i / 2) * 18;
ctx.fillStyle = on ? '#00e5ff' : '#333';
ctx.fillRect(kx, ky, 18, 14);
ctx.fillStyle = on ? '#000' : '#666';
ctx.font = 'bold 11px monospace';
ctx.fillText(k.label, kx + 6, ky + 11);
ctx.font = 'bold 8px monospace';
}
ctx.restore();
}
function drawPlayerAt(p, screenY, opts = {}) {
const ghost = !!opts.ghost;
const label = opts.label || null;
if (!ghost && p.invulnerableTimer > 0 && p.invulnerableTimer <= 2.5 && !p.isDoingStunt360 && Math.floor(Date.now()/100) % 2 === 0) return;
ctx.save();
if (ghost) ctx.globalAlpha = 0.55;
if (p.jumpScale > 1.0) {
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.ellipse(p.x + p.w/2, screenY + p.h/2 + 15, p.w*0.7, p.h*0.4, 0, 0, Math.PI*2);
ctx.fill();
}
ctx.translate(p.x + p.w/2, screenY + p.h/2);
ctx.rotate(p.tiltAngle + p.stuntAngle);
ctx.scale(p.jumpScale, p.jumpScale * p.flipScaleY);
if (p.invulnerableTimer > 0 && !ghost) {
ctx.strokeStyle = Math.floor(Date.now()/70) % 2 === 0 ? '#fffa65' : '#00ffff';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(0, 0, p.h*0.75, 0, Math.PI*2); ctx.stroke();
}
if (p.speed >= 400) {
const fh = 14 + Math.random()*12;
ctx.fillStyle = '#ff3838';
ctx.beginPath(); ctx.moveTo(-p.w/3, p.h/2); ctx.lineTo(-p.w/6, p.h/2 + fh); ctx.lineTo(0, p.h/2); ctx.fill();
ctx.beginPath(); ctx.moveTo(0, p.h/2); ctx.lineTo(p.w/6, p.h/2 + fh); ctx.lineTo(p.w/3, p.h/2); ctx.fill();
ctx.fillStyle = '#fff200'; ctx.fillRect(-p.w/4, p.h/2, p.w/2, fh*0.5);
}
if (p.shieldTimer > 0) {
ctx.strokeStyle = Math.floor(Date.now()/80) % 2 === 0 ? '#00ffff' : '#e056fd';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(0, 0, p.h*0.72, 0, Math.PI*2); ctx.stroke();
}
ctx.fillStyle = p.color;
ctx.fillRect(-p.w/2, -p.h/2, p.w, p.h);
ctx.fillStyle = '#111';
ctx.fillRect(-p.w/2 + 3, -p.h/2 + 10, p.w - 6, 9);
ctx.fillRect(-p.w/2 + 4, p.h/2 - 12, p.w - 8, 6);
ctx.fillStyle = '#000';
ctx.fillRect(-p.w/2 - 2, -p.h/2 + 5, 3, 10); ctx.fillRect(p.w/2 - 1, -p.h/2 + 5, 3, 10);
ctx.fillRect(-p.w/2 - 2, p.h/2 - 15, 3, 10); ctx.fillRect(p.w/2 - 1, p.h/2 - 15, 3, 10);
ctx.fillStyle = '#ffff66';
ctx.fillRect(-p.w/2 + 2, -p.h/2, 4, 3); ctx.fillRect(p.w/2 - 6, -p.h/2, 4, 3);
ctx.restore();
if (label) {
ctx.save();
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
if (ghost) {
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.lineWidth = 3;
ctx.strokeText(label, p.x + p.w/2, screenY - 6);
ctx.fillStyle = '#ffffff';
ctx.fillText(label, p.x + p.w/2, screenY - 6);
} else {
ctx.strokeStyle = 'rgba(0,0,0,0.9)';
ctx.lineWidth = 3;
ctx.strokeText(label, p.x + p.w/2, screenY - 8);
ctx.fillStyle = '#ffe066';
ctx.fillText(label, p.x + p.w/2, screenY - 8);
}
ctx.textAlign = 'left';
ctx.restore();
}
if (!ghost) {
if (Math.abs(p.tiltAngle) > 0.08 && !p.isSpinningOut) {
ctx.fillStyle = '#f1c40f';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
const rem = Math.max(0, 1.8 - p.tiltGraceTimer).toFixed(1);
const hint = p.tiltAngle > 0 ? `<< ВЫРАВНИВАЙ (${rem}с)` : `(${rem}с) ВЫРАВНИВАЙ >>`;
ctx.fillText(hint, p.x + p.w/2, screenY - 22);
ctx.textAlign = 'left';
} else if (p.isSpinningOut) {
ctx.fillStyle = '#e74c3c';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.fillText('ТОРМОЗИ ДО 90!', p.x + p.w/2, screenY - 22);
ctx.textAlign = 'left';
}
}
}
function drawNightEffectForViewport(viewPlayer, vw) {
ctx.save();
const nc = document.createElement('canvas');
nc.width = vw; nc.height = 660;
const nctx = nc.getContext('2d');
nctx.fillStyle = 'rgba(5,7,15,0.91)';
nctx.fillRect(0, 0, vw, 660);
nctx.globalCompositeOperation = 'destination-out';
players.forEach(p => {
if (!p.crashed) {
const sY = syFor(p.dist, viewPlayer.dist);
nctx.beginPath();
nctx.moveTo(p.x + p.w/2, sY + p.h/2);
nctx.lineTo(p.x - 70, sY - 280);
nctx.lineTo(p.x + p.w + 70, sY - 280);
nctx.closePath(); nctx.fill();
nctx.beginPath();
nctx.arc(p.x + p.w/2, sY + p.h/2, 60, 0, Math.PI*2); nctx.fill();
}
});
ctx.drawImage(nc, 0, 0);
ctx.restore();
}
function drawViewportHUD(player, vw) {
const stage = STAGES[currentLevel] || STAGES[0];
ctx.fillStyle = '#1e272e';
const barX = 8, barY = 90, barW = 10, barH = isTwoPlayer ? 300 : 440;
ctx.fillRect(barX, barY, barW, barH);
ctx.strokeStyle = '#485460'; ctx.lineWidth = 1;
ctx.strokeRect(barX, barY, barW, barH);
ctx.fillStyle = '#e74c3c'; ctx.fillRect(barX - 2, barY - 4, barW + 4, 3);
ctx.fillStyle = '#2ecc71'; ctx.fillRect(barX - 2, barY + barH + 1, barW + 4, 3);
const prog = Math.min(1, player.dist / stage.distance);
const my = barY + barH - prog * barH;
ctx.fillStyle = '#f5f6fa'; ctx.fillRect(barX - 2, my, barW + 4, 6);
ctx.fillStyle = '#00e5ff';
ctx.font = 'bold 9px monospace';
ctx.fillText(GAME_VERSION, 8, 14);
const livesLeft = getLivesLeft();
if (livesEnabled) {
let h = '';
for (let i = 0; i < MAX_LIVES; i++) h += (i < livesLeft) ? '❤' : '✖';
ctx.fillStyle = livesLeft > 1 ? '#ff4757' : '#e74c3c';
ctx.font = 'bold 10px monospace';
ctx.fillText(h, 8, 28);
} else {
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 10px monospace';
ctx.fillText('∞', 8, 28);
}
const rx = vw - 6;
ctx.textAlign = 'right';
ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 10px monospace';
ctx.fillText(`ST.${currentLevel + 1}/${STAGES.length}`, rx, 14);
ctx.fillStyle = timeLeft < 15 ? '#e74c3c' : '#fff';
ctx.font = 'bold 14px monospace';
ctx.fillText(`${Math.ceil(timeLeft)}s`, rx, 32);
ctx.fillStyle = player.color;
ctx.font = 'bold 11px monospace';
const tag = isTwoPlayer ? (player.id === 1 ? 'P1' : 'P2') : 'P1';
ctx.fillText(`${tag} [${player.carClass.name}]`, rx, 52);
ctx.font = 'bold 16px monospace';
ctx.fillStyle = player.speed >= 400 ? '#e056fd' : (player.speed >= 330 ? '#ff4757' : (player.isInTurboTunnel ? '#00e5ff' : (player.shieldTimer > 0 ? '#00ffff' : '#2ecc71')));
ctx.fillText(`${Math.round(player.speed)} km/h`, rx, 72);
ctx.font = 'bold 10px monospace';
ctx.fillStyle = '#f1c40f';
ctx.fillText(`SCORE: ${player.score}`, rx, 88);
ctx.fillStyle = '#fff'; ctx.font = '9px monospace';
ctx.fillText('FUEL', rx, 102);
const fw = 60;
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1;
ctx.strokeRect(rx - fw, 106, fw, 8);
ctx.fillStyle = player.fuel < 25 ? '#e74c3c' : '#2ecc71';
ctx.fillRect(rx - fw + 1, 107, (player.fuel/100) * (fw - 2), 6);
let oy = 126;
const sk = (player.id === 2) ? 'O' : 'E';
ctx.fillStyle = player.spinCharges > 0 ? '#00e5ff' : '#7f8c8d';
ctx.font = 'bold 8px monospace';
ctx.fillText(`360[${sk}]:${player.spinCharges}/2`, rx, oy); oy += 11;
if (player.catchUpBoost > 1 && isTwoPlayer) {
ctx.fillStyle = '#ffeb3b'; ctx.fillText(`CATCH-UP +10%`, rx, oy); oy += 11;
}
if (player.jumpTimer > 0) { ctx.fillStyle = '#f1c40f'; ctx.fillText(`FLY ${player.jumpTimer.toFixed(1)}s`, rx, oy); oy += 11; }
if (player.shieldTimer > 0) { ctx.fillStyle = '#00ffff'; ctx.fillText(`SHLD 355 ${player.shieldTimer.toFixed(1)}s`, rx, oy); oy += 11; }
if (player.nitroTimer > 0) { ctx.fillStyle = '#e056fd'; ctx.fillText(`NITRO 480 ${player.nitroTimer.toFixed(1)}s`, rx, oy); oy += 11; }
if (player.handbrakeDebuffTotal > 0) { ctx.fillStyle = '#e74c3c'; ctx.fillText(`HB -${Math.round(player.handbrakeDebuffTotal*100)}%`, rx, oy); oy += 11; }
ctx.textAlign = 'left';
}
// ===== SCREENS =====
function drawPressStartScreen() {
ctx.fillStyle = 'rgba(0,0,0,0.9)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 14px Courier New';
ctx.fillText(`ROAD FIGHTER ${GAME_VERSION}`, canvas.width/2, 145);
ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 20px Courier New';
ctx.fillText('HANDBRAKE RAM AI', canvas.width/2, 180);
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 13px Courier New';
ctx.fillText('2P: слева P1, справа P2', canvas.width/2, 212);
ctx.fillStyle = '#ff6b00'; ctx.font = 'bold 14px Courier New';
ctx.fillText('P2: IJKL (I=газ J=влево K=тормоз L=вправо)', canvas.width/2, 240);
ctx.fillStyle = '#fff'; ctx.font = 'bold 12px Courier New';
ctx.fillText('Трюк: O Ручник: M', canvas.width/2, 262);
ctx.fillStyle = '#e056fd'; ctx.font = 'bold 13px Courier New';
ctx.fillText('САЛЬТО = 5 сек NITRO 480!', canvas.width/2, 288);
ctx.fillStyle = '#00ffff'; ctx.font = 'bold 13px Courier New';
ctx.fillText('ЩИТ = макс 355', canvas.width/2, 308);
ctx.fillStyle = '#ff3333'; ctx.font = 'bold 13px Courier New';
ctx.fillText('ЗА ДОРОГОЙ = СМЕРТЬ!', canvas.width/2, 328);
ctx.fillStyle = '#ff69b4'; ctx.font = 'bold 12px Courier New';
ctx.fillText('Розовая машина дёргает РУЧНИК для тарана!', canvas.width/2, 350);
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 16px Courier New';
if (Math.floor(Date.now()/500) % 2 === 0) ctx.fillText('НАЖМИТЕ ЛЮБУЮ КЛАВИШУ', canvas.width/2, 390);
ctx.font = '13px Courier New'; ctx.fillStyle = '#aaa';
ctx.fillText('(Y или ESC — меню)', canvas.width/2, 418);
ctx.textAlign = 'left';
}
function drawMenuScreen() {
ctx.fillStyle = 'rgba(0,0,0,0.95)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 13px Courier New';
ctx.fillText(`ROAD FIGHTER ${GAME_VERSION}`, canvas.width/2, 55);
if (livesEnabled) {
const ll = getLivesLeft();
let h = '';
for (let i = 0; i < MAX_LIVES; i++) h += (i < ll) ? '❤ ' : '✖ ';
ctx.fillStyle = ll > 1 ? '#2ecc71' : '#e74c3c';
ctx.font = 'bold 12px Courier New';
ctx.fillText(`ЖИЗНИ: ${h}`, canvas.width/2, 80);
} else {
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 12px Courier New';
ctx.fillText('РЕЖИМ: БЕЗ ЖИЗНЕЙ (∞)', canvas.width/2, 80);
}
const optMode = isTwoPlayer ? 'РЕЖИМ: 2 ИГРОКА (SPLIT)' : 'РЕЖИМ: 1 ИГРОК';
const optLives = livesEnabled ? 'ЖИЗНИ: < ВКЛЮЧЕНЫ (3 смерти) >' : 'ЖИЗНИ: < ОТКЛЮЧЕНЫ (∞) >';
const optCar1 = `P1 АВТО: < ${CAR_CLASSES[selectedCarClassP1].name} >`;
const optCar2 = `P2 АВТО: < ${CAR_CLASSES[selectedCarClassP2].name} >`;
const musicNames = ['[ ВЫКЛЮЧЕНА ]', '[ ТРЕК 1 ]', '[ ТРЕК 2 ]', '[ ТРЕК 3 ]'];
const optMusic = `МУЗЫКА: < ${musicNames[selectedMusicTrack]} >`;
const optStart = `▶ СТАРТ (ЭТАП ${currentLevel + 1}) ◀`;
const rows = [];
rows.push({ text: optMode, id: 0 });
rows.push({ text: optLives, id: 1, isLives: true });
rows.push({ text: optCar1, id: 2, sub: CAR_CLASSES[selectedCarClassP1].desc });
if (isTwoPlayer) {
rows.push({ text: optCar2, id: 3, sub: CAR_CLASSES[selectedCarClassP2].desc });
rows.push({ text: optMusic, id: 4 });
rows.push({ text: optStart, id: 5, isStart: true });
} else {
rows.push({ text: optMusic, id: 3 });
rows.push({ text: optStart, id: 4, isStart: true });
}
let startY = 125;
rows.forEach((r, idx) => {
const sel = menuSelection === idx;
ctx.font = 'bold 15px Courier New';
if (r.isStart) {
ctx.fillStyle = sel ? '#2ecc71' : '#7f8c8d';
ctx.fillText(sel ? `★ ${r.text} ★` : r.text, canvas.width/2, startY);
} else if (r.isLives) {
const bc = livesEnabled ? '#ff4757' : '#2ecc71';
ctx.fillStyle = sel ? '#f1c40f' : bc;
ctx.fillText(sel ? `▶ ${r.text} ◀` : r.text, canvas.width/2, startY);
} else {
ctx.fillStyle = sel ? '#f1c40f' : '#888';
ctx.fillText(sel ? `▶ ${r.text} ◀` : r.text, canvas.width/2, startY);
if (r.sub && sel) {
ctx.font = '11px Courier New'; ctx.fillStyle = '#00e5ff';
ctx.fillText(r.sub, canvas.width/2, startY + 18);
}
}
startY += r.sub ? 52 : 44;
});
ctx.font = '11px Courier New'; ctx.fillStyle = '#aaa';
ctx.fillText('ВВЕРХ/ВНИЗ — строки | ВЛЕВО/ВПРАВО — выбор | ENTER — старт', canvas.width/2, 520);
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 11px Courier New';
ctx.fillText('P1 трюк [E] | P2 трюк [O] | Сальто = NITRO 480/5сек', canvas.width/2, 545);
ctx.fillStyle = '#00ffff'; ctx.font = 'bold 11px Courier New';
ctx.fillText('Щит = макс 355 | За дорогой = мгновенная смерть', canvas.width/2, 565);
ctx.fillStyle = '#ff69b4'; ctx.font = 'bold 11px Courier New';
ctx.fillText('Розовая дёргает ручник и таранит + возвращается злее!', canvas.width/2, 585);
ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 10px Courier New';
ctx.fillText('P2 руль: IJKL / Стрелки / Numpad / Цифры 8-4-6-2-5', canvas.width/2, 608);
ctx.textAlign = 'left';
}
function drawOverlay(title, subtitle, color) {
ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.font = 'bold 26px Courier New'; ctx.fillStyle = color;
ctx.fillText(title, canvas.width/2, canvas.height/2 - 20);
ctx.font = '13px Courier New'; ctx.fillStyle = '#fff';
ctx.fillText(subtitle, canvas.width/2, canvas.height/2 + 25);
ctx.textAlign = 'left';
}
function drawGameOverMenu() {
ctx.fillStyle = 'rgba(0,0,0,0.9)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.fillStyle = '#e74c3c'; ctx.font = 'bold 28px Courier New';
ctx.fillText('ВРЕМЯ ВЫШЛО / РАЗБИТ!', canvas.width/2, 170);
ctx.fillStyle = '#ecf0f1'; ctx.font = '14px Courier New';
ctx.fillText(`ЭТАП ${currentLevel + 1}`, canvas.width/2, 205);
if (livesEnabled) {
const ll = getLivesLeft();
ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 13px Courier New';
ctx.fillText(`ЖИЗНЕЙ: ${ll} / ${MAX_LIVES}`, canvas.width/2, 240);
let h = '';
for (let i = 0; i < MAX_LIVES; i++) h += (i < ll) ? '❤ ' : '✖ ';
ctx.fillStyle = ll > 1 ? '#2ecc71' : '#e74c3c'; ctx.font = 'bold 22px Courier New';
ctx.fillText(h, canvas.width/2, 275);
} else {
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 13px Courier New';
ctx.fillText('РЕЖИМ БЕЗ ЖИЗНЕЙ', canvas.width/2, 255);
}
const o1 = '▶ ПРОДОЛЖИТЬ С ТЕКУЩЕГО ◀';
const o2 = '▶ НАЧАТЬ С 1 УРОВНЯ ◀';
ctx.font = 'bold 15px Courier New';
ctx.fillStyle = gameOverSelection === 0 ? '#f1c40f' : '#7f8c8d';
ctx.fillText(gameOverSelection === 0 ? `★ ${o1} ★` : o1, canvas.width/2, 350);
ctx.fillStyle = gameOverSelection === 1 ? '#f1c40f' : '#7f8c8d';
ctx.fillText(gameOverSelection === 1 ? `★ ${o2} ★` : o2, canvas.width/2, 400);
ctx.font = '11px Courier New'; ctx.fillStyle = '#aaa';
ctx.fillText('↑/↓ + ENTER', canvas.width/2, 470);
ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 12px Courier New';
ctx.fillText('Или [Ъ] для рестарта', canvas.width/2, 510);
ctx.textAlign = 'left';
}
function drawAllLivesLostScreen() {
ctx.fillStyle = 'rgba(0,0,0,0.92)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.fillStyle = '#e74c3c'; ctx.font = 'bold 30px Courier New';
ctx.fillText('ВСЕ ЖИЗНИ ПОТЕРЯНЫ!', canvas.width/2, 200);
ctx.fillStyle = '#fff'; ctx.font = 'bold 15px Courier New';
ctx.fillText(`Умерли ${MAX_LIVES} раза`, canvas.width/2, 250);
ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 17px Courier New';
ctx.fillText('СТАРТ С 1-го УРОВНЯ', canvas.width/2, 310);
if (Math.floor(Date.now()/500) % 2 === 0) {
ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 16px Courier New';
ctx.fillText('НАЖМИ [ENTER]', canvas.width/2, 390);
}
ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 12px Courier New';
ctx.fillText('Или [Ъ]', canvas.width/2, 440);
ctx.textAlign = 'left';
}
let lastTime = performance.now();
function gameLoop(time) {
const dt = Math.min((time - lastTime) / 1000, 0.1);
lastTime = time;
update(dt);
draw();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>Game Source: Road Fighter v4.9 - Handbrake Ram AI Edition
Creator: RocketKoala70
Libraries: none
Complexity: complex (1950 lines, 82.3 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: road-fighter-v4-9-handbrake-ram-ai-editi-rocketkoala70" to link back to the original. Then publish at arcadelab.ai/publish.