🎮ArcadeLab

Road Fighter 3D — Independent Split Worlds

by RocketKoala70
1961 lines93.0 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Road Fighter 3D — Independent Split Worlds</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: radial-gradient(circle at 50% 0%, #12182a 0%, #05060a 70%);
  display: flex; flex-direction: column; justify-content: center; align-items: center;
  min-height: 100vh; font-family: 'Courier New', monospace; color:#fff; user-select:none; padding:10px; }
#game-container { position: relative; width: 900px; height: 620px;
  border: 3px solid #2a3550; border-radius: 10px; overflow: hidden;
  box-shadow: 0 0 60px rgba(0,200,255,0.14), 0 0 120px rgba(0,0,0,0.95); background:#000; }
#gameCanvas, #hudCanvas { position: absolute; top: 0; left: 0; width: 900px; height: 620px; display: block; }
#hudCanvas { pointer-events: none; }
.music-slots { margin-top: 10px; display: flex; gap: 8px; align-items: center;
  background: #141721; padding: 8px 14px; border-radius: 8px; border: 1px solid #2d3446; font-size:11px; color:#9aa7bd; }
.slot-btn { background: #2b334a; color: #fff; padding: 6px 10px; border-radius: 5px;
  border: 1px solid #4a5578; cursor: pointer; font-family: inherit; font-size: 11px; }
.slot-btn:hover { background: #3e4b6d; }
input[type="file"] { display: none; }
.panel-info { margin-top: 10px; text-align: center; font-size: 12px; color: #9aa7bd; line-height: 1.6; max-width: 900px; }
.panel-info b { color: #f1c40f; }
</style>
</head>
<body>
<div id="game-container">
  <canvas id="gameCanvas" width="900" height="620"></canvas>
  <canvas id="hudCanvas" width="900" height="620"></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</b>/<b>M</b> | Меню: <b>Стрелки</b> + <b>ENTER</b> | <b>Ъ</b> — рестарт с 1-го уровня<br>
  <b>P1:</b> Руль <b>Ф/В</b> (C/F), HIGH <b>Ц(W)</b>, LOW <b>Ы(S)</b>, Ручник <b>ПРОБЕЛ</b>, Трюк <b>E</b><br>
  <b>P2:</b> <b>I</b>=газ, <b>J</b>=влево, <b>K</b>=тормоз, <b>L</b>=вправо, Трюк <b>O</b>, Ручник <b>M</b><br>
  <b>2P: слева P1, справа P2 — независимые дороги.</b> Сальто = 5с нитро 455 км/ч.
</div>
<audio id="bgAudio" loop></audio>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* ============================================================
   ROAD FIGHTER 3D — INDEPENDENT SPLIT WORLDS
   ============================================================ */
const GAME_VERSION = 'v5.3 DUAL-WORLD';
const W = 900, H = 620;
const ROAD_W = 12, ROAD_HALF = ROAD_W / 2;
const ZSCALE = 0.35, DIST_SCALE = 2.2;
const SPAWN_Z = -470, DESPAWN_Z = 70;
const ROAD_LEN = 2000, TILE_LEN = 10;
const MAX_LIVES = 3;

const FLIP_NITRO_DURATION = 5.0;
const NITRO_MAX_SPEED = 455;
const OIL_SLICK_DURATION = 4.0;
const OIL_SLICK_STEER_MULT = 1 / 1.7;
const TILT_DECEL = 70;
const FUEL_SPAWN_CHANCE = 0.20;
const CATCHUP_BOOST = 1.10;
const CATCHUP_TRIGGER_DIST = 5;

/* Трамплины: реже в 1.5 раза, затем дважды ещё на 20% */
const RAMP_MAIN_CHANCE  = 0.28 / 1.5 * 0.8 * 0.8;   /* ≈ 0.119 */
const RAMP_EXTRA_CHANCE = 0.14 / 1.5 * 0.8 * 0.8;   /* ≈ 0.060 */

const LAYER_SHARED = 0;
const LAYER_P1 = 1;
const LAYER_P2 = 2;

const STAGES = [
  { name:'STAGE 1: ПРИГОРОД', distance:Math.round(25000*1.7*1.5), spawnRate:16, grass:0x0b5313, trafficSpeed:1.05, night:false, rain:false, boss:null, tornado:false, skyTop:'#4a90d9', skyBot:'#c8e0f5' },
  { name:'STAGE 2: СКОРОСТНАЯ ТРАССА', distance:Math.round(30000*1.7*1.5), spawnRate:13, grass:0x16233f, trafficSpeed:1.25, night:false, rain:false, boss:'tanker', tornado:false, skyTop:'#3a7bc8', skyBot:'#a8d0f0' },
  { name:'STAGE 3: АДСКИЙ КАНЬОН', distance:Math.round(36000*1.7*1.5), spawnRate:11, grass:0x4b1313, trafficSpeed:1.45, night:false, rain:false, boss:'heli', tornado:true, skyTop:'#c84a2a', skyBot:'#f4a460' },
  { name:'STAGE 4: НОЧНОЙ ЛИВЕНЬ', distance:Math.round(42000*1.7*1.5), spawnRate:9, grass:0x151b29, trafficSpeed:1.65, night:true, rain:true, boss:null, tornado:false, skyTop:'#03040a', skyBot:'#101828' },
  { name:'STAGE 5: РЕМОНТНАЯ ЗОНА', distance:Math.round(44000*1.7*1.5), spawnRate:8, grass:0x634832, trafficSpeed:1.70, night:false, rain:false, boss:null, tornado:false, skyTop:'#6a7a8a', skyBot:'#a8b0b8' }
];
const CAR_CLASSES = [
  { name:'СПОРТКАР', color:0xff2222, maxSpeed:410, accel:155, steer:11.0, fuelRate:1.00, stability:1.00, desc:'Сбалансированная скорость и манёвренность' },
  { name:'ДРИФТЕР', color:0x00e5ff, maxSpeed:425, accel:175, steer:12.4, fuelRate:1.15, stability:0.75, desc:'Быстрее разгон и руль, но строже к заносам' },
  { name:'ТЯЖЕЛОВЕС', color:0xe67e22, maxSpeed:385, accel:130, steer:9.4, fuelRate:0.85, stability:1.50, desc:'Тяжёлый кузов, стойкий к крену, экономит топливо' }
];
const TRAFFIC_PALETTE = [0xf1c40f,0x00cec9,0xe84393,0x6c5ce7,0x00b894,0xfdcb6e,0xfab1a0,0xdfe6e9,0xfd79a8,0xffeaa7];

let unlockedStageIndex = parseInt(localStorage.getItem('road_fighter3d_checkpoint')||'0',10);
if (isNaN(unlockedStageIndex) || unlockedStageIndex >= STAGES.length) unlockedStageIndex = 0;
let playerDeaths = parseInt(localStorage.getItem('road_fighter3d_deaths')||'0',10);
if (isNaN(playerDeaths) || playerDeaths < 0) playerDeaths = 0;

let currentLevel = unlockedStageIndex;
let isTwoPlayer = false, livesEnabled = true;
let gameState = 'PRESS_START';
let menuSelection = 0, gameOverSelection = 0;
let selectedMusicTrack = 1, selectedCarClassP1 = 0, selectedCarClassP2 = 1;
let stageTimePassed = 0;
let screenShakeTimer = 0, screenShakeMag = 0;
let forcedRestart = false, lifeLostFlashTimer = 0, forcedRestartBannerTimer = 0, manualRestartFlashTimer = 0;
let allLivesLostFlashTimer = 0;
let highScores = JSON.parse(localStorage.getItem('road_fighter3d_records')||'[]');

function getLivesLeft() { return Math.max(0, MAX_LIVES - playerDeaths); }
function resetDeaths() { playerDeaths = 0; localStorage.setItem('road_fighter3d_deaths','0'); }

/* ================= THREE.JS ================= */
const glCanvas = document.getElementById('gameCanvas');
const hudCanvas = document.getElementById('hudCanvas');
const ctx = hudCanvas.getContext('2d');

const renderer = new THREE.WebGLRenderer({ canvas: glCanvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(W, H, false);
renderer.setClearColor(0x05070d, 1);

const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x05070d, 70, 430);

const camP1 = new THREE.PerspectiveCamera(62, W/H, 0.5, 1400);
camP1.position.set(0, 5.6, 12.5); camP1.lookAt(0, 1.2, -18);
const camP2 = new THREE.PerspectiveCamera(62, W/H, 0.5, 1400);
camP2.position.set(0, 5.6, 12.5); camP2.lookAt(0, 1.2, -18);

camP1.layers.set(LAYER_SHARED); camP1.layers.enable(LAYER_P1);
camP2.layers.set(LAYER_SHARED); camP2.layers.enable(LAYER_P2);

scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x223344, 0.95));
const sun = new THREE.DirectionalLight(0xffffff, 0.75);
sun.position.set(35, 90, 45); scene.add(sun);
scene.add(new THREE.AmbientLight(0xffffff, 0.25));
const hemi = scene.children[0], ambient = scene.children[2];

const matCache = new Map();
function getMat(hex) { if (!matCache.has(hex)) matCache.set(hex, new THREE.MeshLambertMaterial({color:hex})); return matCache.get(hex); }
const getBasicMat = (()=>{ const c=new Map(); return h=>{ if(!c.has(h)) c.set(h,new THREE.MeshBasicMaterial({color:h})); return c.get(h); };})();
const GEO = {
  body: new THREE.BoxGeometry(1.7, 0.72, 3.5),
  cabin: new THREE.BoxGeometry(1.42, 0.62, 1.65),
  wheel: (()=>{const g=new THREE.CylinderGeometry(0.33,0.33,0.28,8); g.rotateZ(Math.PI/2); return g;})(),
  light: new THREE.BoxGeometry(0.42, 0.16, 0.12),
  rock: new THREE.DodecahedronGeometry(0.7, 0),
  cone: new THREE.ConeGeometry(0.45, 1.1, 8)
};
const MAT = {
  glass: new THREE.MeshLambertMaterial({color:0x0e1826}),
  tire: new THREE.MeshLambertMaterial({color:0x0a0a0a}),
  head: new THREE.MeshBasicMaterial({color:0xfff6c8}),
  tail: new THREE.MeshBasicMaterial({color:0xff2b2b})
};

function makeRoadTexture() {
  const c=document.createElement('canvas'); c.width=256; c.height=256;
  const g=c.getContext('2d');
  g.fillStyle='#2b3140'; g.fillRect(0,0,256,256);
  for (let i=0;i<500;i++){ g.fillStyle=`rgba(255,255,255,${Math.random()*0.035})`; g.fillRect(Math.random()*256,Math.random()*256,3,3); }
  g.fillStyle='#e8eef5'; g.fillRect(256/3-3,0,6,128); g.fillRect(256*2/3-3,0,6,128);
  g.fillStyle='#f5f7fa'; g.fillRect(3,0,5,256); g.fillRect(248,0,5,256);
  const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping;
  t.repeat.set(1, ROAD_LEN/TILE_LEN); t.anisotropy=4; return t;
}
function makeCurbTexture() {
  const c=document.createElement('canvas'); c.width=16; c.height=64;
  const g=c.getContext('2d');
  g.fillStyle='#e74c3c'; g.fillRect(0,0,16,32);
  g.fillStyle='#ecf0f1'; g.fillRect(0,32,16,32);
  const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping;
  t.repeat.set(1, ROAD_LEN/TILE_LEN); return t;
}
function makeCircleTexture() {
  const c=document.createElement('canvas'); c.width=c.height=64;
  const g=c.getContext('2d');
  const grd=g.createRadialGradient(32,32,0,32,32,32);
  grd.addColorStop(0,'rgba(255,255,255,1)'); grd.addColorStop(0.45,'rgba(255,255,255,0.75)');
  grd.addColorStop(1,'rgba(255,255,255,0)');
  g.fillStyle=grd; g.fillRect(0,0,64,64);
  return new THREE.CanvasTexture(c);
}
function makeSkyTexture(topHex, botHex) {
  const c=document.createElement('canvas'); c.width=32; c.height=256;
  const g=c.getContext('2d');
  const grd=g.createLinearGradient(0,0,0,256);
  grd.addColorStop(0,topHex); grd.addColorStop(1,botHex);
  g.fillStyle=grd; g.fillRect(0,0,32,256);
  return new THREE.CanvasTexture(c);
}
const circleTex = makeCircleTexture();

/* ================= SHARED STATIC SCENE (layer 0) ================= */
const skyMat = new THREE.MeshBasicMaterial({ map:makeSkyTexture('#0a1a3a','#1a2a4a'), side:THREE.BackSide, depthWrite:false, fog:false });
const skyMesh = new THREE.Mesh(new THREE.SphereGeometry(900,24,16), skyMat);
skyMesh.layers.set(LAYER_SHARED); scene.add(skyMesh);

const STARS_COUNT=900;
const starsGeo=new THREE.BufferGeometry();
const starsPos=new Float32Array(STARS_COUNT*3);
for (let i=0;i<STARS_COUNT;i++){
  const r=500+Math.random()*300, th=Math.random()*Math.PI*2, ph=Math.random()*Math.PI*0.45;
  starsPos[i*3]=Math.cos(th)*Math.sin(ph)*r; starsPos[i*3+1]=Math.cos(ph)*r; starsPos[i*3+2]=Math.sin(th)*Math.sin(ph)*r;
}
starsGeo.setAttribute('position', new THREE.BufferAttribute(starsPos,3));
const starsPoints = new THREE.Points(starsGeo, new THREE.PointsMaterial({color:0xffffff,size:1.6,transparent:true,opacity:0.9,fog:false}));
starsPoints.layers.set(LAYER_SHARED); starsPoints.visible=false; scene.add(starsPoints);

const moonMesh = new THREE.Mesh(new THREE.SphereGeometry(14,16,12), new THREE.MeshBasicMaterial({color:0xf0f4ff,fog:false}));
moonMesh.position.set(-180,200,-500); moonMesh.layers.set(LAYER_SHARED); moonMesh.visible=false; scene.add(moonMesh);
const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(18,16,12), new THREE.MeshBasicMaterial({color:0xfff3b0,fog:false}));
sunMesh.position.set(220,180,-550); sunMesh.layers.set(LAYER_SHARED); scene.add(sunMesh);

const mountainsGroup = new THREE.Group();
mountainsGroup.layers.set(LAYER_SHARED); scene.add(mountainsGroup);
(function buildMountains(){
  const mMat=new THREE.MeshBasicMaterial({color:0x0e1520,fog:false});
  const mMat2=new THREE.MeshBasicMaterial({color:0x1a2230,fog:false});
  for (let i=0;i<26;i++){
    const h=50+Math.random()*80, r=30+Math.random()*30;
    const cone=new THREE.Mesh(new THREE.ConeGeometry(r,h,5), i%2?mMat:mMat2);
    cone.layers.set(LAYER_SHARED);
    const side=Math.random()>0.5?1:-1;
    cone.position.set(side*(90+Math.random()*260), h/2-15, -400-Math.random()*400);
    cone.rotation.y=Math.random()*Math.PI;
    mountainsGroup.add(cone);
  }
})();

const groundMat = new THREE.MeshLambertMaterial({color:0x0b5313});
const ground = new THREE.Mesh(new THREE.PlaneGeometry(1600,ROAD_LEN), groundMat);
ground.rotation.x=-Math.PI/2; ground.position.set(0,-0.03,-ROAD_LEN/2+200);
ground.layers.set(LAYER_SHARED); scene.add(ground);

const railMat = new THREE.MeshLambertMaterial({color:0x8a939e});
const railMatLower = new THREE.MeshLambertMaterial({color:0x4a535e});
const poleMat = new THREE.MeshLambertMaterial({color:0x252b36});
const lampMat = new THREE.MeshBasicMaterial({color:0xfff0a0});
const sceneryMats = [
  new THREE.MeshLambertMaterial({color:0x1e5c2a}),
  new THREE.MeshLambertMaterial({color:0x14401d}),
  new THREE.MeshLambertMaterial({color:0x6b4a2c}),
  new THREE.MeshLambertMaterial({color:0x2c3a4a})
];

/* ================= PER-PLAYER WORLD ================= */
function createPlayerWorld(layer) {
  const group = new THREE.Group();
  group.layers.set(layer);
  scene.add(group);

  const roadTex = makeRoadTexture();
  const road = new THREE.Mesh(new THREE.PlaneGeometry(ROAD_W,ROAD_LEN),
    new THREE.MeshLambertMaterial({map:roadTex}));
  road.rotation.x = -Math.PI/2;
  road.position.set(0,0,-ROAD_LEN/2+200);
  road.layers.set(layer);
  group.add(road);

  const curbTexL = makeCurbTexture();
  const curbTexR = makeCurbTexture();
  const curbL = new THREE.Mesh(new THREE.PlaneGeometry(1.0,ROAD_LEN),
    new THREE.MeshLambertMaterial({map:curbTexL}));
  curbL.rotation.x = -Math.PI/2;
  curbL.position.set(-ROAD_HALF-0.5,0.01,-ROAD_LEN/2+200);
  curbL.layers.set(layer); group.add(curbL);

  const curbR = new THREE.Mesh(new THREE.PlaneGeometry(1.0,ROAD_LEN),
    new THREE.MeshLambertMaterial({map:curbTexR}));
  curbR.rotation.x = -Math.PI/2;
  curbR.position.set(ROAD_HALF+0.5,0.01,-ROAD_LEN/2+200);
  curbR.layers.set(layer); group.add(curbR);

  [-1,1].forEach(side => {
    const g = new THREE.Group();
    const r1 = new THREE.Mesh(new THREE.BoxGeometry(0.18,0.35,ROAD_LEN), railMat); r1.position.y=1.05; g.add(r1);
    const r2 = new THREE.Mesh(new THREE.BoxGeometry(0.14,0.14,ROAD_LEN), railMatLower); r2.position.y=0.55; g.add(r2);
    g.position.set(side*(ROAD_HALF+1.4), 0, -ROAD_LEN/2+200);
    g.traverse(o => { if (o.layers) o.layers.set(layer); });
    group.add(g);
  });

  const streetlights = [];
  for (let i=0;i<44;i++){
    const side = i%2===0 ? 1 : -1;
    const g = new THREE.Group();
    const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.16,6.4,6), poleMat); pole.position.y=3.2; g.add(pole);
    const arm = new THREE.Mesh(new THREE.BoxGeometry(1.6,0.12,0.12), poleMat); arm.position.set(-side*0.8,6.3,0); g.add(arm);
    const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.3,8,6), lampMat); lamp.position.set(-side*1.5,6.2,0); g.add(lamp);
    const glow = new THREE.Sprite(new THREE.SpriteMaterial({map:circleTex, color:0xfff0a0, transparent:true, opacity:0.55, blending:THREE.AdditiveBlending, depthWrite:false}));
    glow.scale.set(5,5,1); glow.position.set(-side*1.5,6.2,0); g.add(glow);
    g.position.set(side*(ROAD_HALF+3.2), 0, -i*48+100);
    g.traverse(o => { if (o.layers) o.layers.set(layer); });
    group.add(g);
    streetlights.push(g);
  }

  const sceneryPool = [];
  for (let i=0;i<90;i++){
    const kind=Math.random();
    let mesh;
    if (kind>0.72){ const h=6+Math.random()*16;
      const b=new THREE.Mesh(new THREE.BoxGeometry(3+Math.random()*3,h,3+Math.random()*3), sceneryMats[3]);
      b.position.y=h/2; mesh=b; }
    else if (kind>0.4){
      const g=new THREE.Group();
      const tr=new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.24,1.6,6), sceneryMats[2]); tr.position.y=0.8; g.add(tr);
      const cr=new THREE.Mesh(new THREE.ConeGeometry(1.3,3.4,7), sceneryMats[i%2]); cr.position.y=3.1; g.add(cr);
      mesh=g;
    } else { mesh=new THREE.Mesh(GEO.rock, sceneryMats[2]); mesh.scale.setScalar(0.8+Math.random()*1.6); mesh.position.y=0.4; }
    const side=Math.random()>0.5?1:-1;
    mesh.position.x = side*(ROAD_HALF+8+Math.random()*55);
    mesh.position.z = -Math.random()*ROAD_LEN+100;
    mesh.traverse(o => { if (o.layers) o.layers.set(layer); });
    group.add(mesh);
    sceneryPool.push(mesh);
  }

  const rainGeo = new THREE.BufferGeometry();
  const rainArr = new Float32Array(1100*3);
  for (let i=0;i<1100;i++){
    rainArr[i*3]=(Math.random()-0.5)*140; rainArr[i*3+1]=Math.random()*45; rainArr[i*3+2]=-Math.random()*320+50;
  }
  rainGeo.setAttribute('position', new THREE.BufferAttribute(rainArr,3));
  const rainPoints = new THREE.Points(rainGeo, new THREE.PointsMaterial({color:0xa8d8ff, size:0.35, transparent:true, opacity:0.7, depthWrite:false}));
  rainPoints.layers.set(layer); rainPoints.visible=false;
  group.add(rainPoints);

  const particlePool = [];
  for (let i=0;i<300;i++){
    const s = new THREE.Sprite(new THREE.SpriteMaterial({map:circleTex, transparent:true, depthWrite:false, color:0xffffff}));
    s.visible=false; s.layers.set(layer);
    group.add(s);
    particlePool.push({sprite:s, active:false, life:0, maxLife:1, vx:0, vy:0, vz:0, size:1});
  }

  return {
    group, layer,
    roadTex, curbTexL, curbTexR,
    streetlights, sceneryPool, rainPoints, rainArr,
    particlePool, particleCursor: 0,
    traffic: [], obstacles: [], pickups: [], ramps: [],
    cops: [], pitVans: [], cones: [],
    tornado: null, boss: null,
    ticker: 0, worldSpeed: 50, distanceTraveled: 0
  };
}

/* ================= VEHICLE BUILDERS ================= */
const FLAME_COLORS = [0xff1a00, 0xff7a00, 0xffd400, 0xffffff];
function makeFlames(group, layer){
  const arr = [];
  for (const pos of [[-0.5,0.55,1.95],[0.5,0.55,1.95]]) {
    for (let k=0;k<4;k++){
      const s = new THREE.Sprite(new THREE.SpriteMaterial({map:circleTex, color:FLAME_COLORS[k], transparent:true, blending:THREE.AdditiveBlending, depthWrite:false, opacity:0}));
      s.position.set(pos[0],pos[1],pos[2]+k*0.15); s.scale.set(0.1,0.1,1); s.visible=false;
      s.layers.set(layer);
      group.add(s); arr.push({sprite:s, baseZ:pos[2], layer:k});
    }
  }
  return arr;
}
function makeVehicle(colorHex, layer, withFlames=false){
  const g = new THREE.Group();
  g.rotation.order = 'YXZ';
  const body = new THREE.Mesh(GEO.body, getMat(colorHex)); body.position.y=0.58; g.add(body);
  const cab = new THREE.Mesh(GEO.cabin, MAT.glass); cab.position.set(0,1.06,-0.15); g.add(cab);
  for (const p of [[-0.88,0.33,1.12],[0.88,0.33,1.12],[-0.88,0.33,-1.12],[0.88,0.33,-1.12]]){
    const w = new THREE.Mesh(GEO.wheel, MAT.tire); w.position.set(p[0],p[1],p[2]); g.add(w);
  }
  const hl1=new THREE.Mesh(GEO.light,MAT.head); hl1.position.set(-0.5,0.72,-1.76); g.add(hl1);
  const hl2=new THREE.Mesh(GEO.light,MAT.head); hl2.position.set(0.5,0.72,-1.76); g.add(hl2);
  const tl1=new THREE.Mesh(GEO.light,MAT.tail); tl1.position.set(-0.5,0.72,1.76); g.add(tl1);
  const tl2=new THREE.Mesh(GEO.light,MAT.tail); tl2.position.set(0.5,0.72,1.76); g.add(tl2);
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({map:circleTex, color:0xfff3b0, transparent:true, opacity:0.55, blending:THREE.AdditiveBlending, depthWrite:false}));
  glow.scale.set(2.4,2.4,1); glow.position.set(0,0.72,-1.9); g.add(glow);
  g.userData.bodyMesh = body;
  if (withFlames) g.userData.flames = makeFlames(g, layer);
  g.traverse(o => { if (o.layers) o.layers.set(layer); });
  return g;
}
function makeTruckVehicle(colorHex, layer){
  const g = new THREE.Group(); g.rotation.order='YXZ';
  const cab = new THREE.Mesh(new THREE.BoxGeometry(2.1,1.5,2.2), getMat(colorHex)); cab.position.set(0,1.05,-2.2); g.add(cab);
  const cargo = new THREE.Mesh(new THREE.BoxGeometry(2.3,2.1,5.2), getMat(0xcfd6dd)); cargo.position.set(0,1.35,1.4); g.add(cargo);
  for (const p of [[-1.05,0.42,-2.2],[1.05,0.42,-2.2],[-1.05,0.42,1.6],[1.05,0.42,1.6],[-1.05,0.42,3.6],[1.05,0.42,3.6]]){
    const w = new THREE.Mesh(GEO.wheel, MAT.tire); w.position.set(p[0],p[1],p[2]); w.scale.setScalar(1.25); g.add(w);
  }
  g.userData.bodyMesh = cab;
  g.traverse(o => { if (o.layers) o.layers.set(layer); });
  return g;
}

/* ================= PLAYERS ================= */
let players = [];

function createPlayer(id, startX, classIndex, keyMap){
  const cc = CAR_CLASSES[classIndex];
  const layer = id === 1 ? LAYER_P1 : LAYER_P2;
  const mesh = makeVehicle(cc.color, layer, true);
  scene.add(mesh);
  const world = createPlayerWorld(layer);
  return {
    id, x: startX, z: 0, hw: 0.9, hl: 1.75,
    mesh, layer, world,
    color: cc.color, carClass: cc, keyMap,
    dist: 0, catchUpBoost: 1.0,
    speed: 0, fuel: 100, score: 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, flipAngle: 0,
    dashDir: 0, dashTimer: 0, lastLeftTapTime: 0, lastRightTapTime: 0,
    shieldTimer: 0, nitroTimer: 0, invulnerableTimer: 0,
    jumpTimer: 0, jumpY: 0,
    rainbowOilTimer: 0, oilSlickTimer: 0,
    handbrakeDebuffTotal: 0, handbrakeCooldown: 0,
    crashed: false, crashTimer: 0,
    flameSeed: Math.random() * 1000,
    timeLeft: 90
  };
}

function disposeWorld(w){
  if (!w) return;
  scene.remove(w.group);
  w.group.traverse(o => { if (o.geometry && o.geometry.dispose) o.geometry.dispose(); });
}
function clearPlayers(){
  for (const p of players){
    scene.remove(p.mesh);
    disposeWorld(p.world);
  }
  players = [];
}

/* ================= AUDIO ================= */
const bgAudio = document.getElementById('bgAudio');
const trackUrls = {1:null,2:null,3:null};
['file1','file2','file3'].forEach((id,i)=>{
  document.getElementById(id).addEventListener('change', e=>{
    const f = e.target.files[0]; if (!f) return;
    if (trackUrls[i+1]) URL.revokeObjectURL(trackUrls[i+1]);
    trackUrls[i+1] = URL.createObjectURL(f);
    if (selectedMusicTrack === i+1) playSelectedMusic();
  });
});
function playSelectedMusic(){
  if (selectedMusicTrack === 0){ bgAudio.pause(); return; }
  const url = trackUrls[selectedMusicTrack];
  if (url){ if (bgAudio.src !== 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(){
  if (engineOsc) return;
  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(spd){ if (!engineOsc) return; engineOsc.frequency.setTargetAtTime(45+(spd/410)*105, audioCtx.currentTime, 0.05); }
function playSfx(freq,dur,type='square',ramp=0.001){
  try {
    const o=audioCtx.createOscillator(), g=audioCtx.createGain();
    o.type=type; o.frequency.setValueAtTime(freq, audioCtx.currentTime);
    g.gain.setValueAtTime(0.07, audioCtx.currentTime);
    g.gain.exponentialRampToValueAtTime(ramp, audioCtx.currentTime+dur);
    o.connect(g); g.connect(audioCtx.destination); o.start(); o.stop(audioCtx.currentTime+dur);
  } 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')){
    playSfx(synthNotes[Math.floor(synthTick/18)%synthNotes.length], 0.12, 'triangle', 0.015);
  }
}

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==='*';
}

/* ================= START / RESTART ================= */
function restartFromLevel1(){
  currentLevel = 0; resetDeaths(); forcedRestart = false;
  forcedRestartBannerTimer = 0; lifeLostFlashTimer = 0;
  manualRestartFlashTimer = 0.8; allLivesLostFlashTimer = 0;
  gameOverSelection = 0; localStorage.setItem('road_fighter3d_checkpoint','0');
  if (players.length === 0) initPlayersForRestart();
  loadStage(0); gameState = 'PLAY'; stageTimePassed = 0;
  playSfx(400, 0.35, 'sawtooth');
}
function initPlayersForRestart(){
  clearPlayers();
  const p1x = isTwoPlayer ? -3.0 : 0;
  players.push(createPlayer(1, p1x, selectedCarClassP1, {
    left:['KeyA','KeyC','ф','с'], right:['KeyD','KeyF','в','f','а'],
    low:['KeyS','KeyZ','ы','я'], high:['KeyW','ц'], handbrake:['Space']
  }));
  if (isTwoPlayer) players.push(createPlayer(2, 3.0, selectedCarClassP2, p2KeyMap()));
}

const activeKeys = {};
const PREVENT = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Slash','NumpadDivide','NumpadMultiply',
  'Numpad0','Numpad1','Numpad2','Numpad4','Numpad5','Numpad6','Numpad8','NumpadDecimal','Home','End','Backquote',
  '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.has(code)) e.preventDefault();
  if (e.repeat) return;

  if (code === 'BracketRight' || key === 'ъ' || key === 'Ъ') {
    if (audioCtx.state === 'suspended') audioCtx.resume();
    initEngineSound();
    if (['PLAY','GAMEOVER','STAGE_CLEAR','VICTORY','MENU','ALL_LIVES_LOST'].includes(gameState)) {
      if (players.length === 0) initPlayersForRestart();
      restartFromLevel1();
    }
    return;
  }
  if (code === 'KeyY' || key === 'y' || key === 'н' || code === 'KeyU' || key === 'u' || key === 'г' ||
      code === 'Escape' || code === 'KeyM' || key === 'ь') {
    if (gameState === 'PLAY' || gameState === 'PRESS_START') {
      if (audioCtx.state === 'suspended') audioCtx.resume();
      initEngineSound();
      gameState = 'MENU'; menuSelection = 0;
      playSelectedMusic(); playSfx(420, 0.15);
    }
    return;
  }
  if (gameState === 'PRESS_START') {
    if (audioCtx.state === 'suspended') audioCtx.resume();
    initEngineSound();
    gameState = 'MENU'; playSelectedMusic(); playSfx(420, 0.15); return;
  }
  if (gameState === 'MENU') {
    const rows = isTwoPlayer ? 6 : 5;
    if (code === 'ArrowUp' || code === 'KeyW' || key === 'ц') { menuSelection = (menuSelection - 1 + rows) % rows; playSfx(330, 0.08); }
    if (code === 'ArrowDown' || code === 'KeyS' || key === 'ы') { menuSelection = (menuSelection + 1) % rows; playSfx(330, 0.08); }
    if (code === 'ArrowLeft' || code === 'KeyA' || key === 'ф') handleMenuAdjust(-1);
    if (code === 'ArrowRight' || code === 'KeyD' || key === 'в') handleMenuAdjust(1);
    if (code === 'Enter' || code === 'Space') {
      const startRow = isTwoPlayer ? 5 : 4;
      if (menuSelection === startRow) startGame(); else handleMenuAdjust(1);
      playSfx(480, 0.1);
    }
    return;
  }
  if (gameState === 'GAMEOVER') {
    if (!forcedRestart) {
      if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'KeyW' || code === 'KeyS') { gameOverSelection = 1 - gameOverSelection; playSfx(330, 0.08); }
    }
    if (code === 'Enter' || code === 'Space') {
      if (forcedRestart) restartFromLevel1();
      else if (gameOverSelection === 0) { loadStage(currentLevel); gameState = 'PLAY'; }
      else { currentLevel = 0; resetDeaths(); localStorage.setItem('road_fighter3d_checkpoint','0'); loadStage(0); gameState = 'PLAY'; }
      playSfx(520, 0.2);
    }
    return;
  }
  if (gameState === 'ALL_LIVES_LOST') { if (code === 'Enter' || code === 'Space') restartFromLevel1(); return; }
  if (gameState === 'STAGE_CLEAR') {
    if (code === 'Enter' || code === 'Space') {
      currentLevel++;
      if (currentLevel >= STAGES.length) currentLevel = 0;
      localStorage.setItem('road_fighter3d_checkpoint', String(currentLevel));
      loadStage(currentLevel); gameState = 'PLAY'; playSfx(560, 0.2);
    }
    return;
  }
  if (gameState === 'VICTORY') { if (code === 'Enter' || code === 'Space') { resetDeaths(); gameState = 'MENU'; menuSelection = 0; } return; }
  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' || key === 'а') {
        if (now - players[0].lastRightTapTime < 280) performSideBash(players[0], 1);
        players[0].lastRightTapTime = now;
      }
    }
  }
});
window.addEventListener('keyup', e => {
  activeKeys[e.code] = false;
  if (e.key) activeKeys[e.key.toLowerCase()] = false;
});
function isKeyActive(list){ return list.some(k => activeKeys[k]); }

function handleMenuAdjust(dir){
  if (menuSelection === 0) isTwoPlayer = !isTwoPlayer;
  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();
  }
  playSfx(360, 0.06);
}

function startGame(){
  initEngineSound(); if (audioCtx.state === 'suspended') audioCtx.resume();
  playSelectedMusic();
  playerDeaths = 0; localStorage.setItem('road_fighter3d_deaths','0');
  forcedRestart = false; forcedRestartBannerTimer = 0; lifeLostFlashTimer = 0;
  manualRestartFlashTimer = 0; allLivesLostFlashTimer = 0; gameOverSelection = 0;
  clearPlayers();
  stageTimePassed = 0;
  initPlayersForRestart();
  loadStage(currentLevel); gameState = 'PLAY'; playSfx(520, 0.25);
}

/* ================= SPAWN / WORLD LOGIC (per player) ================= */
function spawnParticle(world, x, y, z, vx, vy, vz, color, size, life){
  const p = world.particlePool[world.particleCursor];
  world.particleCursor = (world.particleCursor + 1) % world.particlePool.length;
  p.active = true; p.life = life; p.maxLife = life; p.vx = vx; p.vy = vy; p.vz = vz; p.size = size;
  p.sprite.position.set(x, y, z);
  p.sprite.material.color.setHex(color);
  p.sprite.material.opacity = 1;
  p.sprite.scale.set(size, size, 1);
  p.sprite.visible = true;
}
function updateParticles(world, dt, worldDz){
  for (const p of world.particlePool){
    if (!p.active) continue;
    p.life -= dt;
    if (p.life <= 0){ p.active = false; p.sprite.visible = false; continue; }
    p.sprite.position.x += p.vx*dt; p.sprite.position.y += p.vy*dt; p.sprite.position.z += p.vz*dt + worldDz;
    if (p.sprite.position.y < 0.1){ p.sprite.position.y = 0.1; p.vy *= -0.3; }
    const t = p.life/p.maxLife;
    p.sprite.material.opacity = t;
    p.sprite.scale.setScalar(p.size*(0.5+t*0.8));
  }
}

function spawnTrafficCar(world, stage, layer){
  const laneX = (Math.random()-0.5) * (ROAD_W - 3.6);
  const tooClose = world.traffic.some(c => c.z < -SPAWN_Z*0.75 && Math.abs(c.x-laneX) < 2.2);
  if (tooClose) return;
  const r = Math.random();
  const color = TRAFFIC_PALETTE[(Math.random()*TRAFFIC_PALETTE.length)|0];
  const isCounter = (laneX < -ROAD_W/6) && (Math.random() < 0.35);
  const base = {
    x: laneX, z: SPAWN_Z, hw: 0.9, hl: 1.75,
    speed: isCounter ? -160 : 130 * stage.trafficSpeed,
    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, dir: 1
  };
  let mesh;
  if (isCounter || r < 0.20){ mesh = makeVehicle(isCounter ? 0xff3838 : color, layer); }
  else if (r < 0.36){ base.speed = 165*stage.trafficSpeed; base.type='shifter'; base.dir = Math.random()>0.5?1:-1; mesh = makeVehicle(0x0984e3, layer); }
  else if (r < 0.48){ base.speed = 145*stage.trafficSpeed; base.type='purple'; base.isPurpleAgressor=true; mesh = makeVehicle(0x8e44ad, layer); }
  else if (r < 0.58){ base.speed = 110*stage.trafficSpeed; base.type='taxi'; mesh = makeVehicle(0xe17055, layer); }
  else if (r < 0.66){ base.speed = 95*stage.trafficSpeed; base.type='truck'; base.hw=1.25; base.hl=3.4; mesh = makeTruckVehicle(0xff6b00, layer); }
  else if (r < 0.72){ spawnObstacle(world, laneX, 'oil_black', layer); return; }
  else if (r < 0.78){ spawnObstacle(world, laneX, 'oil_rainbow', layer); return; }
  else if (r < 0.78 + RAMP_MAIN_CHANCE){ spawnRamp(world, laneX, layer); return; }
  else { spawnObstacle(world, laneX, 'rock', layer); return; }
  if (mesh){
    mesh.position.set(base.x, 0, base.z);
    if (isCounter) mesh.rotation.y = Math.PI;
    world.group.add(mesh);
    base.mesh = mesh; base.colorHex = color;
    world.traffic.push(base);
  }
}
function spawnObstacle(world, x, type, layer){
  let mesh, hw, hl;
  if (type === 'rock'){
    mesh = new THREE.Mesh(GEO.rock, getMat(0x7f8c8d)); mesh.scale.setScalar(1.1);
    mesh.position.set(x, 0.5, SPAWN_Z); hw=0.8; hl=0.8;
  } else {
    const color = type === 'oil_black' ? 0x08080c : 0x7a2a8a;
    mesh = new THREE.Mesh(new THREE.CircleGeometry(1.2, 16),
      new THREE.MeshBasicMaterial({color, transparent:true, opacity: type==='oil_black'?0.95:0.7}));
    mesh.rotation.x = -Math.PI/2; mesh.position.set(x, 0.03, SPAWN_Z); hw=1.1; hl=1.1;
  }
  mesh.layers.set(layer);
  world.group.add(mesh);
  world.obstacles.push({ mesh, x, z:SPAWN_Z, hw, hl, type });
}
function spawnPickup(world, x, type, layer){
  const colors = { FUEL:0x27ae60, TIME:0xf39c12, NITRO:0x9b59b6, SHIELD:0x2980b9 };
  const g = new THREE.Group();
  g.add(new THREE.Mesh(new THREE.BoxGeometry(1.1,1.1,1.1), getMat(colors[type])));
  const wire = new THREE.Mesh(new THREE.BoxGeometry(1.25,1.25,1.25),
    new THREE.MeshBasicMaterial({color:0xffffff, wireframe:true, transparent:true, opacity:0.5}));
  g.add(wire);
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({map:circleTex, color:colors[type], transparent:true, opacity:0.6, blending:THREE.AdditiveBlending, depthWrite:false}));
  glow.scale.set(4,4,1); g.add(glow);
  g.position.set(x, 1.1, SPAWN_Z);
  g.traverse(o => { if (o.layers) o.layers.set(layer); });
  world.group.add(g);
  world.pickups.push({ mesh:g, x, z:SPAWN_Z, hw:0.8, hl:0.8, type, pulse:0 });
}
function spawnRamp(world, x, layer){
  const g = new THREE.Group();
  const geo = new THREE.BufferGeometry();
  const w=2.6, h=1.1, l=4.0;
  const v = new Float32Array([
    -w/2,0,l/2, w/2,0,l/2, 0,h,-l/2,
    -w/2,0,-l/2, w/2,0,-l/2, 0,h,-l/2,
    -w/2,0,l/2, 0,h,-l/2, -w/2,0,-l/2,
    w/2,0,l/2, w/2,0,-l/2, 0,h,-l/2
  ]);
  geo.setAttribute('position', new THREE.BufferAttribute(v,3)); geo.computeVertexNormals();
  g.add(new THREE.Mesh(geo, new THREE.MeshLambertMaterial({color:0xf1c40f, side:THREE.DoubleSide})));
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({
    map:circleTex, color:0xffd700, transparent:true, opacity:0.55,
    blending:THREE.AdditiveBlending, depthWrite:false
  }));
  glow.scale.set(3.2, 1.6, 1); glow.position.set(0, 0.35, 0); g.add(glow);
  g.position.set(x, 0, SPAWN_Z);
  g.traverse(o => { if (o.layers) o.layers.set(layer); });
  world.group.add(g);
  world.ramps.push({ mesh:g, x, z:SPAWN_Z, hw:1.3, hl:2.0 });
}

function spawnWorld(player){
  const world = player.world;
  const stage = STAGES[currentLevel];
  const layer = player.layer;
  world.ticker++;

  if (stage.tornado && !world.tornado &&
      world.distanceTraveled > stage.distance * 0.25 && world.distanceTraveled < stage.distance * 0.65) {
    const g = new THREE.Group();
    const layers = [];
    for (let i=0;i<6;i++){
      const r = 3.2 - i*0.35;
      const ring = new THREE.Mesh(new THREE.TorusGeometry(r, 0.12+i*0.05, 6, 16),
        new THREE.MeshBasicMaterial({color: i%2===0 ? 0xe67e22 : 0xf39c12, transparent:true, opacity:0.85}));
      ring.rotation.x = Math.PI/2; ring.position.y = i*0.55; g.add(ring); layers.push(ring);
    }
    g.traverse(o => { if (o.layers) o.layers.set(layer); });
    world.group.add(g);
    world.tornado = { mesh:g, x:0, z:SPAWN_Z, vx:9, rot:0, radius:3.4, layers };
    playSfx(140, 0.8, 'sawtooth');
  }

  if (stage.boss && !world.boss && world.distanceTraveled > stage.distance * 0.68) {
    if (stage.boss === 'tanker'){
      const mesh = makeTruckVehicle(0xc0392b, layer); mesh.scale.setScalar(1.25);
      world.group.add(mesh);
      world.boss = { type:'tanker', mesh, x:-2.5, z:SPAWN_Z, hw:1.6, hl:4.5, speed:130, hp:12, tick:0 };
      playSfx(220, 0.6, 'sawtooth');
    } else if (stage.boss === 'heli'){
      const g = new THREE.Group();
      g.add(new THREE.Mesh(new THREE.BoxGeometry(3.0,1.3,4.2), getMat(0x2d3436)));
      const rotor = new THREE.Mesh(new THREE.BoxGeometry(9,0.14,0.5), getMat(0x636e72));
      rotor.position.y = 1.0; g.add(rotor);
      const tail = new THREE.Mesh(new THREE.BoxGeometry(0.4,0.4,3.4), getMat(0x2d3436));
      tail.position.set(0,0.2,3.4); g.add(tail);
      g.position.y = 7;
      g.traverse(o => { if (o.layers) o.layers.set(layer); });
      world.group.add(g);
      world.boss = { type:'heli', mesh:g, rotor, x:0, z:SPAWN_Z, hw:1.8, hl:2.4, speed:150, hp:10, tick:0, rotorAngle:0 };
      playSfx(620, 0.6, 'sine');
    }
  }

  /* Дополнительный спавн трамплина — реже (RAMP_EXTRA_CHANCE ≈ 0.060) */
  if (Math.random() < RAMP_EXTRA_CHANCE) {
    const rx = (Math.random()-0.5) * (ROAD_W - 2.4);
    const tooClose = world.ramps.some(r => r.z < -SPAWN_Z*0.85 && Math.abs(r.x - rx) < 3.5);
    if (!tooClose) spawnRamp(world, rx, layer);
  }

  if (world.ticker % stage.spawnRate === 0) {
    const count = Math.random() < 0.35 ? 2 : 1;
    for (let k=0;k<count;k++) spawnTrafficCar(world, stage, layer);
    if (Math.random() < FUEL_SPAWN_CHANCE) {
      const bX = (Math.random()-0.5) * (ROAD_W - 2.6);
      const roll = Math.random(); let type = 'FUEL';
      if (roll > 0.75){ const o = Math.random(); type = o>0.66 ? 'NITRO' : (o>0.33 ? 'TIME' : 'SHIELD'); }
      spawnPickup(world, bX, type, layer);
    }
    if (world.pitVans.length === 0 && Math.random() < 0.05) {
      const g = new THREE.Group();
      const b = new THREE.Mesh(new THREE.BoxGeometry(2.4,2.4,5.0), getMat(0x00b894)); b.position.y=1.5; g.add(b);
      for (const q of [[-1.1,0.42,-1.6],[1.1,0.42,-1.6],[-1.1,0.42,1.6],[1.1,0.42,1.6]]){
        const w = new THREE.Mesh(GEO.wheel, MAT.tire); w.position.set(q[0],q[1],q[2]); w.scale.setScalar(1.2); g.add(w);
      }
      const x = (Math.random()-0.5) * (ROAD_W-4); g.position.set(x,0,SPAWN_Z-60);
      g.traverse(o => { if (o.layers) o.layers.set(layer); });
      world.group.add(g);
      world.pitVans.push({ mesh:g, x, z:SPAWN_Z-60, hw:1.25, hl:2.6, speed:110 });
    }
    if (world.cops.length === 0 && Math.random() < 0.08 && currentLevel >= 1) {
      const g = new THREE.Group();
      const b = new THREE.Mesh(new THREE.BoxGeometry(1.8,0.8,3.6), getMat(0x111111)); b.position.y=0.6; g.add(b);
      const white = new THREE.Mesh(new THREE.BoxGeometry(1.82,0.45,1.8), getMat(0xf5f6fa)); white.position.y=0.75; g.add(white);
      const siren = new THREE.Mesh(new THREE.BoxGeometry(1.1,0.22,0.4), getBasicMat(0xe74c3c)); siren.position.set(0,1.2,-0.2); g.add(siren);
      for (const q of [[-0.95,0.33,1.1],[0.95,0.33,1.1],[-0.95,0.33,-1.1],[0.95,0.33,-1.1]]){
        const w = new THREE.Mesh(GEO.wheel, MAT.tire); w.position.set(q[0],q[1],q[2]); g.add(w);
      }
      const x = Math.random() > 0.5 ? -ROAD_HALF+1.5 : ROAD_HALF-1.5;
      g.position.set(x,0,DESPAWN_Z+40);
      g.traverse(o => { if (o.layers) o.layers.set(layer); });
      world.group.add(g);
      world.cops.push({ mesh:g, x, z:DESPAWN_Z+40, hw:0.9, hl:1.8, speed:460, sirenTick:0, sirenMesh:siren });
      playSfx(780, 0.4, 'sawtooth');
    }
    if ((currentLevel+1) % 5 === 0 && Math.random() < 0.35) {
      const x = (Math.random()>0.5 ? -1 : 1) * (ROAD_HALF - 1.2 - Math.random()*1.4);
      const m = new THREE.Mesh(GEO.cone, getMat(0xe67e22)); m.position.set(x,0.55,SPAWN_Z);
      m.layers.set(layer); world.group.add(m);
      world.cones.push({ mesh:m, x, z:SPAWN_Z, hw:0.4, hl:0.4 });
    }
  }
}

/* ================= STUNTS / CRASH ================= */
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++) spawnParticle(p.world, p.x+dir*1.0, 0.6, (Math.random()-0.5)*1.6, dir*(4+Math.random()*5), 1+Math.random()*2, (Math.random()-0.5)*3, 0xffffff, 0.9, 0.35);
}
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.42; p.stuntDuration = 0.42; 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.flipAngle = 0;
  p.nitroTimer = Math.max(p.nitroTimer, FLIP_NITRO_DURATION);
  p.speed = Math.max(p.speed, NITRO_MAX_SPEED);
  playSfx(680, 0.3, 'sine'); playSfx(900, 0.4, 'triangle', 0.01);
  triggerShake(0.15, 4);
  for (let i = 0; i < 20; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = 3 + Math.random() * 6;
    spawnParticle(p.world, p.x, 0.6, 1.5, Math.cos(a)*s, 1 + Math.random()*3, Math.sin(a)*s,
      [0xff5a00, 0xffb400, 0xff00ff, 0x00e5ff][(Math.random()*4)|0], 1.3, 0.5);
  }
}
function triggerShake(d, m){ screenShakeTimer = d; screenShakeMag = m; }

function createExplosion(world, x, y, z){
  triggerShake(0.35, 6); playSfx(75, 0.45, 'sawtooth');
  const cols = [0xff1111,0xff9900,0xffff00,0xffffff];
  for (let i=0;i<24;i++){
    const a = Math.random()*Math.PI*2, s = 3+Math.random()*8;
    spawnParticle(world, x, y, z, Math.cos(a)*s, 1+Math.random()*5, Math.sin(a)*s, cols[(Math.random()*4)|0], 1.4, 0.5+Math.random()*0.4);
  }
}
function crashPlayer(p){
  if (p.crashed || p.invulnerableTimer > 0) return;
  p.crashed = true; p.crashTimer = 1.1; p.speed = 0;
  p.jumpTimer = 0; p.jumpY = 0; p.nitroTimer = 0;
  p.isDoingStunt360 = false; p.stuntAngle = 0; p.isDoingFlip = false; p.flipAngle = 0;
  p.rainbowOilTimer = 0; p.oilSlickTimer = 0;
  triggerShake(0.4, 7);
  createExplosion(p.world, p.x, 0.8, 0);

  if (livesEnabled && p.id === 1 && gameState === 'PLAY') {
    playerDeaths++; localStorage.setItem('road_fighter3d_deaths', String(playerDeaths));
    lifeLostFlashTimer = 0.7; playSfx(85, 0.7, 'sawtooth');
    if (playerDeaths >= MAX_LIVES) { allLivesLostFlashTimer = 0.6; p.crashTimer = 1.6; }
    else { playSfx(260, 0.3, 'triangle'); }
  } else { playSfx(200, 0.15, 'triangle'); }
}

/* ================= PURPLE AI ================= */
function updatePurpleAI(car, dt, worldSpeed, player){
  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;
  car.aiTimer -= dt;

  if (!player || player.crashed){ car.speed = 130 * STAGES[currentLevel].trafficSpeed; return; }

  const dx = player.x - car.x;
  const closeRange = Math.abs(car.z) < 60;

  if (car.aiTimer <= 0) {
    car.aiTimer = 1.5 + Math.random() * 1.5;
    const r = Math.random();
    if (closeRange) {
      if (r < 0.35) car.aiTactic = 'RAM';
      else if (r < 0.65) car.aiTactic = 'SIDE';
      else car.aiTactic = 'BLOCK';
    } else if (car.z > 40) car.aiTactic = 'PURSUE';
    else car.aiTactic = 'WAIT';
  }
  switch (car.aiTactic) {
    case 'PURSUE': car.speed = 430 + Math.random() * 40; car.x += Math.sign(dx) * 3.5 * dt; break;
    case 'WAIT': car.speed = Math.max(90, worldSpeed * 0.75); car.x += Math.sign(dx) * 2.8 * dt; break;
    case 'RAM':
      car.speed = worldSpeed * 0.98;
      if (Math.abs(dx) > 1.2) car.x += Math.sign(dx) * 5.5 * dt;
      else { if (car.z > 4) car.speed = worldSpeed * 1.20; else if (car.z < -4) car.speed = worldSpeed * 0.80; }
      break;
    case 'SIDE':
      car.speed = worldSpeed;
      if (Math.abs(car.z) < 15) {
        car.x += car.sideDir * 6.0 * dt;
        if (car.sideDir > 0 && car.x > player.x + 1.5) car.sideDir = -1;
        if (car.sideDir < 0 && car.x < player.x - 1.5) car.sideDir = 1;
      } else car.x += Math.sign(dx) * 4.0 * dt;
      break;
    case 'BLOCK': car.speed = worldSpeed * 0.85; car.x += Math.sign(dx) * 3.0 * dt; break;
  }
  const margin = 1.0;
  if (car.x < -ROAD_HALF + margin) car.x = -ROAD_HALF + margin;
  if (car.x > ROAD_HALF - margin) car.x = ROAD_HALF - margin;
}

/* ================= UPDATE (per-player world) ================= */
function hitPlayer(p, o){ return Math.abs(p.x-o.x) < (p.hw+o.hw) && Math.abs(o.z) < (p.hl+o.hl); }

function updatePlayer(player, dt){
  const world = player.world;
  const stage = STAGES[currentLevel];
  const layer = player.layer;

  world.ticker++;
  player.timeLeft -= dt;
  if (player.timeLeft <= 0){ player.timeLeft = 0; return; }

  if (world.rainPoints.visible){
    const arr = world.rainArr;
    for (let i=0;i<1100;i++){
      arr[i*3+1] -= 60*dt; arr[i*3] -= 14*dt;
      if (arr[i*3+1] < 0){ arr[i*3+1]=45; arr[i*3]=(Math.random()-0.5)*140; arr[i*3+2]=-Math.random()*320+50; }
    }
    world.rainPoints.geometry.attributes.position.needsUpdate = true;
  }

  const p = player;
  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.oilSlickTimer > 0) p.oilSlickTimer -= 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 * 9.0 * dt; }
  if (p.jumpTimer > 0){
    p.jumpTimer -= dt;
    const prog = 1.0 - Math.max(0, p.jumpTimer);
    p.jumpY = Math.sin(prog*Math.PI) * 2.1;
    if (p.isDoingFlip) p.flipAngle = prog * Math.PI * 2;
    if (p.jumpTimer <= 0){ p.jumpTimer = 0; p.jumpY = 0; p.isDoingFlip = false; p.flipAngle = 0;
      triggerShake(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.crashed){
    p.crashTimer -= dt;
    if (p.crashTimer <= 0 && allLivesLostFlashTimer > 0 && p.id === 1 && livesEnabled){
      saveHighScore(players[0].score, stageTimePassed);
      gameState = 'ALL_LIVES_LOST'; return;
    }
    if (p.crashTimer <= 0){
      p.crashed = false;
      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);
    }
  } else {
    if (p.speed > 0){
      p.fuel -= (0.016 + (p.speed/410)*0.046) * p.carClass.fuelRate;
      if (p.fuel <= 0){ p.fuel = 0; p.speed = Math.max(0, p.speed - 120*dt); }
    }

    const flames = p.mesh.userData.flames;
    if (flames){
      const hot = p.speed >= 330;
      for (const f of flames){
        if (!hot){ if (f.sprite.visible) f.sprite.visible = false; continue; }
        const t = (Date.now()*0.01 + f.layer*71 + p.flameSeed*13)%1000/1000;
        const flick = 0.65 + Math.sin(t*42)*0.2 + Math.random()*0.35;
        const intensity = Math.min(1, (p.speed-320)/160);
        const size = (0.55+f.layer*0.12)*flick*(0.7+intensity*0.6);
        f.sprite.scale.set(size, size, 1);
        f.sprite.material.opacity = (0.9-f.layer*0.18)*intensity;
        f.sprite.position.z = f.baseZ + f.layer*0.15 + t*0.35;
        f.sprite.visible = true;
      }
      if (hot && Math.random() < 0.5){
        for (let s=0;s<2;s++){
          const side = Math.random()>0.5 ? 0.5 : -0.5;
          spawnParticle(world, p.x+side, 0.55, 2.0, (Math.random()-0.5)*1.5, 0.5+Math.random()*1.5, 4+Math.random()*4,
            [0xff5a00,0xffb400,0xffffff][(Math.random()*3)|0], 0.6, 0.35);
        }
      }
    }
    if (p.nitroTimer > 0 && Math.random() < 0.85) {
      for (let s = 0; s < 3; s++) {
        const side = (Math.random() - 0.5) * 1.2;
        spawnParticle(world, p.x + side, 0.6, 2.2,
          (Math.random()-0.5)*2, 1 + Math.random()*2, 8 + Math.random()*6,
          [0xff00ff, 0x00e5ff, 0xffb400, 0xffffff][(Math.random()*4)|0], 0.9, 0.45);
      }
    }

    const keyL = isKeyActive(p.keyMap.left), keyR = isKeyActive(p.keyMap.right);
    const keyLow = isKeyActive(p.keyMap.low), keyHigh = isKeyActive(p.keyMap.high);
    const keyHB = isKeyActive(p.keyMap.handbrake);

    if (keyHB){
      p.speed = 0;
      if (p.handbrakeCooldown <= 0){
        p.handbrakeCooldown = 1.2;
        if (Math.random() < 0.63){ p.handbrakeDebuffTotal = Math.min(0.35, p.handbrakeDebuffTotal + (0.01+Math.random()*0.07)); playSfx(110, 0.35, 'square'); }
      }
    }

    let baseMax = p.carClass.maxSpeed;
    if (p.shieldTimer > 0) baseMax = Math.min(baseMax, 310);
    else if (p.nitroTimer > 0) baseMax = NITRO_MAX_SPEED;
    baseMax *= p.catchUpBoost;
    const targetMax = baseMax * (1 - p.handbrakeDebuffTotal);

    let targetSpeed = 0;
    if (p.fuel > 0){
      if (keyHigh || p.nitroTimer > 0) targetSpeed = targetMax;
      else if (keyLow) targetSpeed = Math.min(205, targetMax);
    }

    if (p.isSpinningOut) p.speed = Math.max(0, p.speed * (1 - 0.15*dt));
    else if (p.tiltAngle !== 0) p.speed = Math.max(0, p.speed - TILT_DECEL*dt);
    else if (!keyHB && !p.isDoingStunt360){
      const accel = (p.nitroTimer > 0 ? 340 : p.carClass.accel) * dt;
      if (p.speed < targetSpeed) p.speed = Math.min(targetSpeed, p.speed + accel);
      else if (p.speed > targetSpeed){
        const brake = (p.shieldTimer > 0 ? 250 : 170) * dt;
        p.speed = Math.max(targetSpeed, p.speed - brake);
      }
    }

    const gripMod = stage.rain ? 0.72 : 1.0;
    const stab = p.carClass.stability * gripMod;

    if (p.isSpinningOut){
      p.tiltAngle += p.angularVel * dt; p.x += p.driftVel * dt * 0.09;
      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 * 0.02;
      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 {
      let sf = 1.0;
      if (p.rainbowOilTimer > 0) sf *= 0.45;
      if (p.oilSlickTimer > 0) sf *= OIL_SLICK_STEER_MULT;
      const ss = p.carClass.steer * sf * dt;
      if (keyL) p.x -= ss;
      if (keyR) p.x += ss;
    }

    const lim = ROAD_HALF - 1.0;
    if (p.x < -lim || p.x > lim){ p.x = Math.max(-lim, Math.min(lim, p.x)); crashPlayer(p); }

    if (!p.crashed){
      const isCounter = p.x < -ROAD_W/6;
      p.score += Math.floor((p.speed*dt) * (isCounter ? 0.48 : 0.16));
      p.dist += p.speed * dt;
    }
  }

  const worldSpeed = Math.max(50, p.speed);
  world.worldSpeed = worldSpeed;
  const worldDz = worldSpeed * ZSCALE * dt;
  world.distanceTraveled += worldSpeed * dt * DIST_SCALE;

  world.roadTex.offset.y += worldDz / TILE_LEN;
  world.curbTexL.offset.y += worldDz / TILE_LEN;
  world.curbTexR.offset.y += worldDz / TILE_LEN;

  for (const s of world.sceneryPool){
    s.position.z += worldDz;
    if (s.position.z > 130){ s.position.z -= ROAD_LEN; s.position.x = (Math.random()>0.5?1:-1)*(ROAD_HALF+8+Math.random()*55); }
  }
  for (const sl of world.streetlights){
    sl.position.z += worldDz;
    if (sl.position.z > 140) sl.position.z -= 44*48;
  }

  updateParticles(world, dt, worldDz);
  spawnWorld(p);

  /* Tornado */
  if (world.tornado){
    const tor = world.tornado;
    tor.z += worldDz * 0.85;
    tor.x += tor.vx * dt;
    tor.rot += 5 * dt;
    if (tor.x < -ROAD_HALF+2){ tor.x = -ROAD_HALF+2; tor.vx = Math.abs(tor.vx); }
    if (tor.x >  ROAD_HALF-2){ tor.x =  ROAD_HALF-2; tor.vx = -Math.abs(tor.vx); }
    tor.mesh.position.set(tor.x, 0, tor.z);
    tor.mesh.rotation.y = tor.rot;
    tor.layers.forEach((l,i) => { l.rotation.z = tor.rot * (1 + i*0.35); });
    for (let i=world.traffic.length-1;i>=0;i--){
      const c = world.traffic[i];
      if (Math.hypot(c.x-tor.x, c.z-tor.z) < tor.radius + 1.5){
        createExplosion(world, c.x, 0.8, c.z);
        world.group.remove(c.mesh); world.traffic.splice(i,1);
      }
    }
    if (!p.crashed && p.invulnerableTimer <= 0 && Math.hypot(p.x-tor.x, p.z-tor.z) < tor.radius + 1.0){
      p.jumpTimer = 1.6; p.isSpinningOut = true; p.angularVel = 16.0;
      p.driftVel = (Math.random()-0.5)*260;
      triggerShake(0.5, 8); playSfx(300, 0.4, 'sawtooth');
    }
    if (tor.z > DESPAWN_Z + 60){ world.group.remove(tor.mesh); world.tornado = null; }
  }

  /* Boss */
  if (world.boss){
    const b = world.boss;
    b.z += (worldSpeed - b.speed) * ZSCALE * dt;
    b.tick++;
    if (b.type === 'tanker'){
      if (b.z > -60) b.speed = worldSpeed;
      b.mesh.position.set(b.x, 0, b.z);
      if (b.tick % 45 === 0){
        const ox = b.x + (Math.random()-0.5)*2.5;
        spawnObstacle(world, ox, 'oil_black', layer);
        world.obstacles[world.obstacles.length-1].z = b.z + 4;
        world.obstacles[world.obstacles.length-1].mesh.position.z = b.z + 4;
      }
      if (!p.crashed && p.jumpTimer === 0 && hitPlayer(p, b)){
        if (p.dashTimer > 0){ b.hp--; createExplosion(world, p.x, 1.0, b.z); playSfx(280, 0.2);
          if (b.hp <= 0){ createExplosion(world, b.x, 1.5, b.z); p.score += 5000; world.group.remove(b.mesh); world.boss = null; }
        } else if (p.invulnerableTimer <= 0) crashPlayer(p);
      }
    } else if (b.type === 'heli'){
      b.rotorAngle += dt * 35; b.rotor.rotation.y = b.rotorAngle;
      b.mesh.position.set(b.x, 7, b.z);
      b.x += (p.x - b.x) * 1.5 * dt;
      b.x = Math.max(-ROAD_HALF+1, Math.min(ROAD_HALF-1, b.x));
      if (b.tick % 95 === 0){
        const dropX = b.x, dropZ = b.z;
        for (let i=0;i<10;i++) spawnParticle(world, dropX, 6, dropZ, (Math.random()-0.5)*2, -8, (Math.random()-0.5)*2, 0xff2222, 1.2, 0.6);
        playSfx(380, 0.3, 'sawtooth');
        setTimeout(() => {
          createExplosion(world, dropX, 0.8, dropZ);
          if (!p.crashed && Math.abs(p.x-dropX) < 2.0 && Math.abs(p.z-dropZ) < 2.5 && p.jumpTimer === 0 && p.invulnerableTimer <= 0) crashPlayer(p);
        }, 550);
      }
      if (b.z > DESPAWN_Z + 80){ world.group.remove(b.mesh); world.boss = null; }
    }
  }

  /* Cones */
  for (let i=world.cones.length-1;i>=0;i--){
    const c = world.cones[i]; c.z += worldDz; c.mesh.position.z = c.z;
    if (!p.crashed && p.jumpTimer === 0 && p.invulnerableTimer <= 0 && hitPlayer(p, c)){
      p.speed = Math.max(40, p.speed - 40);
      p.tiltAngle = (p.x < c.x ? -1 : 1) * 0.15;
      p.angularVel = (p.x < c.x ? -1 : 1) * 0.6;
      p.tiltGraceTimer = 0; playSfx(200, 0.12, 'square');
      world.group.remove(c.mesh); world.cones.splice(i,1);
    } else if (c.z > DESPAWN_Z + 20){ world.group.remove(c.mesh); world.cones.splice(i,1); }
  }

  /* Obstacles */
  for (let i=world.obstacles.length-1;i>=0;i--){
    const o = world.obstacles[i]; o.z += worldDz; o.mesh.position.z = o.z;
    if (!p.crashed && p.jumpTimer === 0 && p.invulnerableTimer <= 0 && hitPlayer(p, o)){
      if (o.type === 'oil_black'){
        p.oilSlickTimer = OIL_SLICK_DURATION;
        playSfx(180, 0.35, 'sawtooth', 0.01); playSfx(120, 0.25, 'triangle', 0.01);
      } else if (o.type === 'oil_rainbow'){ p.rainbowOilTimer = 3.5; playSfx(380, 0.25, 'triangle'); }
      else if (o.type === 'rock'){
        if (p.shieldTimer > 0){ createExplosion(world, o.x, 0.5, o.z); world.group.remove(o.mesh); world.obstacles.splice(i,1); }
        else crashPlayer(p);
      }
    }
    if (o && o.z > DESPAWN_Z + 20){ world.group.remove(o.mesh); world.obstacles.splice(i,1); }
  }

  /* Ramps */
  for (let i=world.ramps.length-1;i>=0;i--){
    const r = world.ramps[i]; r.z += worldDz; r.mesh.position.z = r.z;
    if (!p.crashed && p.jumpTimer === 0 && hitPlayer(p, r)){ p.jumpTimer = 1.0; playSfx(480, 0.3, 'sine'); }
    if (r.z > DESPAWN_Z + 20){ world.group.remove(r.mesh); world.ramps.splice(i,1); }
  }

  /* Cops */
  for (let i=world.cops.length-1;i>=0;i--){
    const c = world.cops[i];
    c.z += (worldSpeed - c.speed) * ZSCALE * dt;
    c.sirenTick++;
    c.sirenMesh.material = getBasicMat(Math.floor(c.sirenTick/6)%2===0 ? 0xe74c3c : 0x0984e3);
    if (!p.crashed){
      const pushLeft = p.x < 0, desiredX = p.x + (pushLeft ? 1.6 : -1.6);
      c.x += (desiredX - c.x) * 3.5 * dt;
      c.x = Math.max(-ROAD_HALF+1, Math.min(ROAD_HALF-1, c.x));
      if (hitPlayer(p, c) && p.jumpTimer === 0){
        if (p.dashTimer > 0 || p.invulnerableTimer > 0 || p.shieldTimer > 0){
          createExplosion(world, c.x, 0.8, c.z); world.group.remove(c.mesh); world.cops.splice(i,1); continue;
        }
        p.x += (pushLeft ? -1 : 1) * 2.0 * dt;
        p.speed = Math.max(50, p.speed - 80 * dt); playSfx(160, 0.15, 'sawtooth');
      }
    }
    c.mesh.position.set(c.x, 0, c.z);
    if (c.z < -220){ world.group.remove(c.mesh); world.cops.splice(i,1); }
  }

  /* Pit vans */
  for (let i=world.pitVans.length-1;i>=0;i--){
    const v = world.pitVans[i]; v.z += (worldSpeed - v.speed) * ZSCALE * dt; v.mesh.position.z = v.z;
    if (!p.crashed && p.jumpTimer === 0 && hitPlayer(p, v)){
      p.handbrakeDebuffTotal = 0; p.oilSlickTimer = 0; p.fuel = 100; p.speed = Math.max(p.speed, 280);
      playSfx(950, 0.35, 'triangle'); playSfx(1200, 0.35, 'sine');
      for (let k=0;k<16;k++){
        const a = Math.random()*Math.PI*2;
        spawnParticle(world, v.x, 1.2, v.z, Math.cos(a)*5, 2+Math.random()*4, Math.sin(a)*5, 0x00ff66, 1.1, 0.6);
      }
      world.group.remove(v.mesh); world.pitVans.splice(i,1);
    } else if (v && (v.z > DESPAWN_Z + 40 || v.z < -600)){ world.group.remove(v.mesh); world.pitVans.splice(i,1); }
  }

  /* Pickups */
  for (let i=world.pickups.length-1;i>=0;i--){
    const pk = world.pickups[i];
    pk.z += worldDz; pk.pulse += dt*4;
    pk.mesh.position.z = pk.z;
    pk.mesh.position.y = 1.1 + Math.sin(pk.pulse) * 0.25;
    pk.mesh.rotation.y += dt * 2.5;
    if (!p.crashed && p.jumpTimer === 0 && hitPlayer(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'){ player.timeLeft = Math.min(125, player.timeLeft + 12); p.score += 1000; playSfx(800, 0.18); }
      else if (pk.type === 'NITRO'){ p.nitroTimer = FLIP_NITRO_DURATION; p.speed = NITRO_MAX_SPEED; p.score += 1500; playSfx(950, 0.3); }
      else if (pk.type === 'SHIELD'){ p.shieldTimer = 8.0; p.score += 1200; playSfx(880, 0.25); }
      world.group.remove(pk.mesh); world.pickups.splice(i,1);
    } else if (pk.z > DESPAWN_Z + 20){ world.group.remove(pk.mesh); world.pickups.splice(i,1); }
  }

  /* Traffic */
  for (let i=world.traffic.length-1;i>=0;i--){
    const car = world.traffic[i];
    car.z += (worldSpeed - car.speed) * ZSCALE * dt;
    if (car.angryRacer && !car.angryTriggered && car.z > 20){
      car.angryTriggered = true; car.speed = 400;
      car.mesh.userData.bodyMesh.material = getMat(0xff0033);
      playSfx(480, 0.3, 'sawtooth');
    }
    if (car.isPurpleAgressor && !car.isSpinning && car.tiltAngle === 0){
      updatePurpleAI(car, dt, worldSpeed, p);
    }
    if (car.revengeActive && car.revengeAttempts > 0){
      if (!p.crashed){
        if (!car.ambushTriggered && Math.abs(car.z) < 30) car.ambushTriggered = true;
        if (car.ambushTriggered){ const s = car.x < p.x ? 1 : -1; car.x += s * 5.5 * dt; }
        car.speed = car.z < 0 ? worldSpeed * 0.95 : worldSpeed * 1.02;
      }
    }
    if (car.isSpinning){
      car.tiltAngle += car.angularVel * dt; car.x += car.driftVel * dt * 0.02;
      car.speed = Math.max(30, car.speed - 120*dt);
      if (car.x <= -ROAD_HALF+0.8 || car.x >= ROAD_HALF-0.8){
        createExplosion(world, car.x, 0.8, car.z); world.group.remove(car.mesh); world.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 * 0.02;
      if (Math.abs(car.tiltAngle) > 0.7){ car.isSpinning = true; car.angularVel = (car.tiltAngle>0?1:-1)*11.0; }
      if (car.x <= -ROAD_HALF+0.8 || car.x >= ROAD_HALF-0.8){
        createExplosion(world, car.x, 0.8, car.z); world.group.remove(car.mesh); world.traffic.splice(i,1); continue;
      }
    } else if (car.type === 'shifter'){
      car.x += car.dir * 2.6 * dt;
      const lim = ROAD_HALF - 1.2;
      if (car.x < -lim){ car.x = -lim; car.dir = 1; }
      if (car.x >  lim){ car.x =  lim; car.dir = -1; }
    }
    car.mesh.position.set(car.x, 0, car.z);
    car.mesh.rotation.y = (car.type === 'counter' ? Math.PI : 0) - car.tiltAngle;
    car.mesh.rotation.z = -car.tiltAngle * 0.35;

    if (!p.crashed && p.jumpTimer === 0 && hitPlayer(p, car)){
      const canSmash = (p.dashTimer > 0 || p.invulnerableTimer > 0) && car.type !== 'truck';
      if (canSmash){
        createExplosion(world, car.x, 0.8, car.z); p.score += 800;
        world.group.remove(car.mesh); world.traffic.splice(i,1); continue;
      }
      if (car.type === 'truck' || car.type === 'counter'){ crashPlayer(p); continue; }
      if (p.shieldTimer > 0){
        createExplosion(world, car.x, 0.8, car.z); p.score += 600;
        world.group.remove(car.mesh); world.traffic.splice(i,1); continue;
      }
      const isPurple = car.isPurpleAgressor === true;
      p.speed = Math.max(50, p.speed - (isPurple ? 110 : 75));
      const hitDir = p.x < car.x ? -1 : 1;
      if (!p.isSpinningOut){
        p.tiltAngle = hitDir * ((isPurple ? 0.32 : 0.22) / p.carClass.stability);
        p.angularVel = hitDir * (isPurple ? 1.25 : 0.95);
        p.tiltGraceTimer = 0;
      }
      if (Math.random() < 0.65 && !car.isSpinning){
        car.tiltAngle = -hitDir * 0.24; car.angularVel = -hitDir * 0.95;
      } else { car.x += (hitDir < 0 ? 1.2 : -1.2); }
      playSfx(160, 0.25, 'sawtooth');
    }

    if (car.z > DESPAWN_Z + 30 || car.z < -700){ world.group.remove(car.mesh); world.traffic.splice(i,1); }
  }
}

/* ================= STAGE / MAIN UPDATE ================= */
function loadStage(lvl){
  stageTimePassed = 0;
  lifeLostFlashTimer = 0;
  const stage = STAGES[lvl];
  groundMat.color.setHex(stage.grass);
  const fogColor = stage.night ? 0x05070d : (stage.rain ? 0x1a2330 : 0x0a1420);
  scene.fog.color.setHex(fogColor);
  scene.fog.near = stage.night ? 40 : 70; scene.fog.far = stage.night ? 260 : 430;
  hemi.intensity = stage.night ? 0.28 : 0.95;
  sun.intensity = stage.night ? 0.10 : 0.75;
  ambient.intensity = stage.night ? 0.10 : 0.25;
  skyMat.map.dispose(); skyMat.map = makeSkyTexture(stage.skyTop, stage.skyBot); skyMat.needsUpdate = true;
  starsPoints.visible = !!stage.night; moonMesh.visible = !!stage.night; sunMesh.visible = !stage.night;

  players.forEach(p => {
    const w = p.world;
    for (const c of w.traffic) w.group.remove(c.mesh);
    for (const o of w.obstacles) w.group.remove(o.mesh);
    for (const pk of w.pickups) w.group.remove(pk.mesh);
    for (const r of w.ramps) w.group.remove(r.mesh);
    for (const c of w.cops) w.group.remove(c.mesh);
    for (const v of w.pitVans) w.group.remove(v.mesh);
    for (const c of w.cones) w.group.remove(c.mesh);
    if (w.tornado){ w.group.remove(w.tornado.mesh); w.tornado = null; }
    if (w.boss){ w.group.remove(w.boss.mesh); w.boss = null; }
    w.traffic = []; w.obstacles = []; w.pickups = []; w.ramps = [];
    w.cops = []; w.pitVans = []; w.cones = [];
    w.ticker = 0;
    w.distanceTraveled = 0;
    w.worldSpeed = 50;
    w.rainPoints.visible = !!stage.rain;

    p.x = isTwoPlayer ? (p.id === 1 ? -3.0 : 3.0) : 0;
    p.dist = 0; p.catchUpBoost = 1.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.jumpY = 0;
    p.isDoingStunt360 = false; p.stuntAngle = 0; p.stuntTimer = 0;
    p.spinCharges = 2; p.spinRechargeTimer = 0; p.dashTimer = 0;
    p.isDoingFlip = false; p.flipAngle = 0;
    p.rainbowOilTimer = 0; p.oilSlickTimer = 0;
    p.invulnerableTimer = 0; p.handbrakeDebuffTotal = 0; p.handbrakeCooldown = 0;
    p.fuel = 100; p.score = 0;
    p.timeLeft = 80 + Math.floor(Math.random() * 21);
    p.mesh.position.set(p.x, 0, 0); p.mesh.rotation.set(0,0,0); p.mesh.visible = true;
    if (p.mesh.userData.flames) for (const f of p.mesh.userData.flames) f.sprite.visible = false;
  });
}

function update(dt){
  if (lifeLostFlashTimer > 0) lifeLostFlashTimer -= dt;
  if (forcedRestartBannerTimer > 0) forcedRestartBannerTimer -= dt;
  if (manualRestartFlashTimer > 0) manualRestartFlashTimer -= dt;
  if (allLivesLostFlashTimer > 0) allLivesLostFlashTimer -= dt;

  if (gameState !== 'PLAY') return;
  updateSynthMusic();
  stageTimePassed += dt;
  if (screenShakeTimer > 0) screenShakeTimer -= dt;

  if (isTwoPlayer && players.length === 2){
    const [p1, p2] = players;
    const diff = p1.dist - p2.dist;
    if (diff > CATCHUP_TRIGGER_DIST){ p1.catchUpBoost = 1.0; p2.catchUpBoost = CATCHUP_BOOST; }
    else if (-diff > CATCHUP_TRIGGER_DIST){ p1.catchUpBoost = CATCHUP_BOOST; p2.catchUpBoost = 1.0; }
    else { p1.catchUpBoost = 1.0; p2.catchUpBoost = 1.0; }
  } else if (players[0]) players[0].catchUpBoost = 1.0;

  for (const p of players){
    if (p.timeLeft <= 0) continue;
    updatePlayer(p, dt);
  }

  let highest = 0;
  for (const p of players) if (p.speed > highest) highest = p.speed;
  updateEngineSound(highest);

  const stage = STAGES[currentLevel];
  for (const p of players){
    if (p.world.distanceTraveled >= 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;
    }
  }

  let allTimedOut = players.length > 0;
  for (const p of players) if (p.timeLeft > 0) allTimedOut = false;
  if (allTimedOut){
    saveHighScore(players[0].score, stageTimePassed);
    gameState = 'GAMEOVER'; gameOverSelection = 0;
  }
}

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_fighter3d_records', JSON.stringify(highScores));
}

function syncPlayerMeshes(dt){
  for (const p of players){
    if (!p) continue;
    p.mesh.position.set(p.x, p.jumpY, 0);
    p.mesh.rotation.y = -(p.tiltAngle + p.stuntAngle);
    p.mesh.rotation.z = -p.tiltAngle * 0.35;
    p.mesh.rotation.x = p.isDoingFlip ? p.flipAngle : 0;

    if (p.shieldTimer > 0){
      if (!p.shieldMesh){
        const s = new THREE.Mesh(new THREE.SphereGeometry(1.9,14,10),
          new THREE.MeshBasicMaterial({color:0x00ffff, wireframe:true, transparent:true, opacity:0.45}));
        s.layers.set(p.layer);
        p.mesh.add(s); p.shieldMesh = s;
      }
      p.shieldMesh.visible = true;
      p.shieldMesh.rotation.y += dt*4; p.shieldMesh.rotation.x += dt*2;
    } else if (p.shieldMesh) p.shieldMesh.visible = false;

    if (p.nitroTimer > 0) {
      if (!p.nitroAura) {
        const aura = new THREE.Mesh(
          new THREE.SphereGeometry(1.7, 12, 8),
          new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true, transparent: true, opacity: 0.35, blending: THREE.AdditiveBlending })
        );
        aura.layers.set(p.layer);
        p.mesh.add(aura); p.nitroAura = aura;
      }
      p.nitroAura.visible = true;
      p.nitroAura.rotation.y += dt * 8;
      p.nitroAura.rotation.x += dt * 5;
      const pulse = 1 + Math.sin(Date.now()/60) * 0.08;
      p.nitroAura.scale.setScalar(pulse);
      const col = (Math.floor(Date.now()/100) % 2 === 0) ? 0xff00ff : 0x00e5ff;
      p.nitroAura.material.color.setHex(col);
    } else if (p.nitroAura) {
      p.nitroAura.visible = false;
    }

    if (p.invulnerableTimer > 0 && p.invulnerableTimer <= 2.5){
      p.mesh.visible = Math.floor(Date.now()/90)%2===0;
    } else if (!p.crashed) p.mesh.visible = true;
  }
}
function updateCameraFor(player, cam, dt){
  if (!player) return;
  const back = isTwoPlayer ? 11.5 : 12.5;
  const height = 5.6;
  cam.position.x += (player.x * 0.75 - cam.position.x) * Math.min(1, dt*5);
  cam.position.y += (height - cam.position.y) * Math.min(1, dt*4);
  cam.position.z += (back - cam.position.z) * Math.min(1, dt*4);
  if (screenShakeTimer > 0){
    cam.position.x += (Math.random()-0.5) * screenShakeMag * 0.06;
    cam.position.y += (Math.random()-0.5) * screenShakeMag * 0.06;
  }
  cam.lookAt(player.x * 0.85, 1.2, -20);
}

/* ============================================================
   РЕНДЕР: 2P — SPLIT SCREEN (P1 LEFT, P2 RIGHT)
   ============================================================ */
function renderScene(){
  if (isTwoPlayer && players.length === 2){
    const halfW = W / 2;
    renderer.setScissorTest(true);

    renderer.setViewport(0, 0, halfW, H);
    renderer.setScissor(0, 0, halfW, H);
    renderer.render(scene, camP1);

    renderer.setViewport(halfW, 0, halfW, H);
    renderer.setScissor(halfW, 0, halfW, H);
    renderer.render(scene, camP2);

    renderer.setScissorTest(false);
  } else {
    renderer.setViewport(0, 0, W, H);
    renderer.setScissor(0, 0, W, H);
    renderer.render(scene, camP1);
  }
}

/* ================= HUD ================= */
function drawHUD(){
  ctx.clearRect(0, 0, W, H);
  if (!['PLAY','STAGE_CLEAR','GAMEOVER','ALL_LIVES_LOST'].includes(gameState)) return;

  const stage = STAGES[currentLevel] || STAGES[0];

  if (lifeLostFlashTimer > 0){ ctx.fillStyle = `rgba(255,20,20,${lifeLostFlashTimer*0.55})`; ctx.fillRect(0,0,W,H); }
  if (manualRestartFlashTimer > 0){
    ctx.fillStyle = `rgba(0,255,130,${manualRestartFlashTimer*0.35})`; ctx.fillRect(0,0,W,H);
    ctx.textAlign = 'center'; ctx.fillStyle = `rgba(0,255,130,${Math.min(1,manualRestartFlashTimer*2)})`;
    ctx.font = 'bold 34px Courier New'; ctx.fillText('РЕСТАРТ С 1-ГО УРОВНЯ', W/2, 150); ctx.textAlign = 'left';
  }
  if (forcedRestartBannerTimer > 0){
    ctx.fillStyle = `rgba(255,0,0,${Math.min(1,forcedRestartBannerTimer*2)})`;
    ctx.font = 'bold 46px Courier New'; ctx.textAlign='center';
    ctx.fillText('3 СМЕРТИ !', W/2, H/2); ctx.textAlign='left';
  }

  if (players[0] && players[0].nitroTimer > 0 && !isTwoPlayer){
    const a = Math.min(0.4, players[0].nitroTimer * 0.1);
    const t = Date.now() / 200;
    const col1 = (Math.floor(t) % 2 === 0) ? '255,0,255' : '0,229,255';
    const grd = ctx.createRadialGradient(W/2,H/2,180, W/2,H/2,620);
    grd.addColorStop(0,'rgba(0,0,0,0)'); grd.addColorStop(1,`rgba(${col1},${a})`);
    ctx.fillStyle = grd; ctx.fillRect(0,0,W,H);
  }
  if (players[0] && players[0].oilSlickTimer > 0 && !isTwoPlayer){
    const a = Math.min(0.35, players[0].oilSlickTimer*0.12);
    const grd = ctx.createRadialGradient(W/2,H/2,180, W/2,H/2,620);
    grd.addColorStop(0,'rgba(0,0,0,0)'); grd.addColorStop(1,`rgba(70,30,90,${a})`);
    ctx.fillStyle = grd; ctx.fillRect(0,0,W,H);
  }

  const vg = ctx.createRadialGradient(W/2,H/2,200, W/2,H/2,620);
  vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.55)');
  ctx.fillStyle = vg; ctx.fillRect(0,0,W,H);

  if (players[0] && players[0].speed > 260 && !isTwoPlayer){
    const intensity = Math.min(1, (players[0].speed-260)/220);
    const count = Math.floor(intensity * 30);
    for (let i=0;i<count;i++){
      const a = Math.random()*Math.PI*2;
      const r1 = 240+Math.random()*130, r2 = r1+35+Math.random()*70;
      const col = players[0].nitroTimer > 0
        ? (Math.random() > 0.5 ? '255,0,255' : '0,229,255')
        : '255,255,255';
      ctx.strokeStyle = `rgba(${col},${intensity*0.55})`;
      ctx.lineWidth = players[0].nitroTimer > 0 ? 2.2 : 1.5;
      ctx.beginPath(); ctx.moveTo(W/2+Math.cos(a)*r1, H/2+Math.sin(a)*r1); ctx.lineTo(W/2+Math.cos(a)*r2, H/2+Math.sin(a)*r2); ctx.stroke();
    }
  }

  if (isTwoPlayer && players.length === 2){
    ctx.fillStyle = '#000'; ctx.fillRect(W/2-2, 0, 4, H);
    ctx.strokeStyle = '#4a5578'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H); ctx.stroke();

    ctx.textAlign = 'center';
    ctx.font = 'bold 12px Courier New';
    ctx.fillStyle = 'rgba(255,71,87,0.85)';
    ctx.fillText('◀ P1', W/4, 16);
    ctx.textAlign = 'left';
    drawPlayerHUD(players[0], 16, 30, 'left');

    ctx.textAlign = 'center';
    ctx.font = 'bold 12px Courier New';
    ctx.fillStyle = 'rgba(0,229,255,0.85)';
    ctx.fillText('P2 ▶', W*3/4, 16);
    ctx.textAlign = 'left';
    drawPlayerHUD(players[1], W - 16, 30, 'right');

    drawP2KeyIndicator(W/2 + 20, H - 92);

    const p1Behind = players[1].dist > players[0].dist + CATCHUP_TRIGGER_DIST;
    const p2Behind = players[0].dist > players[1].dist + CATCHUP_TRIGGER_DIST;
    if (p1Behind) {
      ctx.textAlign = 'center'; ctx.font = 'bold 11px Courier New';
      ctx.fillStyle = '#ffeb3b'; ctx.fillText('🔥 CATCH-UP +10%', W/4, 60);
      ctx.textAlign = 'left';
    }
    if (p2Behind) {
      ctx.textAlign = 'center'; ctx.font = 'bold 11px Courier New';
      ctx.fillStyle = '#ffeb3b'; ctx.fillText('🔥 CATCH-UP +10%', W*3/4, 60);
      ctx.textAlign = 'left';
    }
  } else if (players[0]){
    drawSinglePlayerHUD(players[0]);
  }

  if (!isTwoPlayer){
    ctx.fillStyle = 'rgba(5,8,15,0.55)'; ctx.fillRect(0, 0, W, 46);
    ctx.strokeStyle = 'rgba(0,229,255,0.25)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(0, 46.5); ctx.lineTo(W, 46.5); ctx.stroke();
    ctx.textAlign = 'left';
    ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 12px Courier New';
    ctx.fillText(GAME_VERSION, 14, 20);
    ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 13px Courier New';
    ctx.fillText(stage.name, 14, 38);

    if (livesEnabled) drawHearts(W-16, 28, getLivesLeft());
    else { ctx.textAlign='right'; ctx.fillStyle='#8892a6'; ctx.font='bold 11px Courier New'; ctx.fillText('РЕЖИМ БЕЗ ЖИЗНЕЙ', W-16, 28); }

    ctx.textAlign = 'center';
    const tl = players[0] ? players[0].timeLeft : 0;
    const tCol = tl < 15 ? '#e74c3c' : '#fff';
    ctx.fillStyle = tCol; ctx.font = 'bold 24px Courier New';
    ctx.fillText(`${Math.ceil(tl)}s`, W/2, 32);
    ctx.fillStyle = '#8892a6'; ctx.font = '9px Courier New';
    ctx.fillText('TIME LEFT', W/2, 43);
  }

  ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(255,255,255,0.30)'; ctx.font = 'bold 10px Courier New';
  ctx.fillText('нажми  Ъ  для рестарта с 1-го уровня', W/2, H-8);
  ctx.textAlign = 'left';
}
function drawPlayerHUD(p, x, y, align){
  if (!p) return;
  const tag = p.id === 1 ? 'P1' : 'P2';
  ctx.save();
  if (align === 'right') ctx.textAlign = 'right';
  ctx.font = 'bold 12px Courier New';
  ctx.fillStyle = '#' + p.carClass.color.toString(16).padStart(6,'0');
  ctx.fillText(`${tag} [${p.carClass.name}]`, x, y);
  ctx.font = 'bold 22px Courier New';
  ctx.fillStyle = p.nitroTimer > 0 ? '#ff00ff' : (p.speed >= 330 ? '#ff4757' : (p.shieldTimer > 0 ? '#00ffff' : '#2ecc71'));
  ctx.fillText(`${Math.round(p.speed)} km/h`, x, y + 24);
  ctx.font = 'bold 11px Courier New';
  ctx.fillStyle = '#f1c40f';
  ctx.fillText(`SCORE: ${p.score}`, x, y + 42);
  ctx.font = '9px Courier New';
  if (p.nitroTimer > 0){ ctx.fillStyle = '#ff00ff'; ctx.fillText(`🔥 NITRO 455 ${p.nitroTimer.toFixed(1)}s`, x, y + 56); }
  if (p.oilSlickTimer > 0){ ctx.fillStyle = '#a05adf'; ctx.fillText(`МАСЛО ×1/1.7 (${p.oilSlickTimer.toFixed(1)}s)`, x, y + 68); }
  if (p.shieldTimer > 0){ ctx.fillStyle = '#00ffff'; ctx.fillText(`SHIELD ${p.shieldTimer.toFixed(1)}s`, x, y + 80); }

  ctx.textAlign = align === 'right' ? 'right' : 'left';
  const tCol = p.timeLeft < 15 ? '#e74c3c' : '#fff';
  ctx.fillStyle = tCol; ctx.font = 'bold 16px Courier New';
  ctx.fillText(`${Math.ceil(p.timeLeft)}s`, x, y + 100);

  ctx.restore();
  ctx.textAlign = 'left';
}
function drawSinglePlayerHUD(p){
  if (!p) return;
  ctx.save();
  ctx.fillStyle = 'rgba(5,8,15,0.55)';
  ctx.fillRect(16, H - 150, 240, 138);
  ctx.strokeStyle = 'rgba(0,229,255,0.20)'; ctx.lineWidth = 1;
  ctx.strokeRect(16.5, H - 149.5, 239, 137);

  const tx = 26, ty = H - 150;
  ctx.textAlign = 'left';
  ctx.fillStyle = '#' + p.carClass.color.toString(16).padStart(6,'0');
  ctx.font = 'bold 11px Courier New';
  ctx.fillText(`P1 [${p.carClass.name}]`, tx, ty + 18);
  if (livesEnabled) drawHearts(232, ty + 20, getLivesLeft());

  const spdCol = p.nitroTimer > 0 ? '#ff00ff' : (p.speed >= 330 ? '#ff4757' : (p.shieldTimer > 0 ? '#00ffff' : '#2ecc71'));
  ctx.fillStyle = spdCol; ctx.font = 'bold 30px Courier New';
  ctx.fillText(`${Math.round(p.speed)}`, tx, ty + 52);
  ctx.fillStyle = '#8892a6'; ctx.font = '10px Courier New';
  ctx.fillText('km/h', tx + 74, ty + 52);

  if (p.nitroTimer > 0){
    ctx.fillStyle = '#ff00ff'; ctx.font = 'bold 10px Courier New';
    ctx.fillText('🔥 NITRO 455', tx + 120, ty + 52);
  } else if (p.speed >= 330){
    ctx.fillStyle = '#ff5a00'; ctx.font = 'bold 10px Courier New';
    ctx.fillText('🔥  BURNING EXHAUST', tx + 120, ty + 52);
  }

  ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 12px Courier New';
  ctx.fillText(`SCORE: ${p.score}`, tx, ty + 72);

  ctx.fillStyle = '#8892a6'; ctx.font = '10px Courier New';
  ctx.fillText('FUEL', tx, ty + 88);
  ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.strokeRect(tx + 36, ty + 79, 100, 11);
  ctx.fillStyle = p.fuel < 25 ? '#e74c3c' : '#2ecc71';
  ctx.fillRect(tx + 38, ty + 81, (p.fuel/100) * 96, 7);

  let oy = ty + 104;
  ctx.font = 'bold 9px Courier New';
  ctx.fillStyle = p.spinCharges > 0 ? '#00e5ff' : '#7f8c8d';
  ctx.fillText(`STUNT 360 [E]: ${p.spinCharges}/2  |  САЛЬТО = 5с NITRO 455`, tx, oy); oy += 11;

  const line = (txt, col) => { ctx.fillStyle = col; ctx.fillText(txt, tx, oy); oy += 11; };
  if (p.invulnerableTimer > 0) line(`НЕУЯЗВИМ: ${p.invulnerableTimer.toFixed(1)}s`, '#fffa65');
  if (p.isDoingStunt360) line('360 SPINNING!', '#f1c40f');
  if (p.nitroTimer > 0) line(`🔥 NITRO 455: ${p.nitroTimer.toFixed(1)}s`, '#ff00ff');
  if (p.rainbowOilTimer > 0) line(`РАДУЖНАЯ НЕФТЬ: ${p.rainbowOilTimer.toFixed(1)}s`, '#ff007f');
  if (p.oilSlickTimer > 0) line(`МАСЛО ×1/1.7 (${p.oilSlickTimer.toFixed(1)}s)`, '#a05adf');
  if (p.jumpTimer > 0) line(`ПОЛЁТ: ${p.jumpTimer.toFixed(1)}s`, '#f1c40f');
  if (p.shieldTimer > 0) line(`SHIELD: ${p.shieldTimer.toFixed(1)}s`, '#00ffff');
  if (p.handbrakeDebuffTotal > 0) line(`РУЧНИК: -${Math.round(p.handbrakeDebuffTotal*100)}%`, '#e74c3c');
  if (p.isSpinningOut) line('ЗАНОС — ТОРМОЗИ!', '#e74c3c');

  ctx.restore();
}
function drawHearts(rightX, y, alive){
  if (alive < 0) alive = 0;
  ctx.font = 'bold 18px Courier New'; ctx.textAlign = 'right';
  let hx = rightX;
  for (let i=0; i<MAX_LIVES; i++){
    const filled = i < alive;
    if (filled){ ctx.fillStyle = '#ff2b4a'; ctx.shadowColor = '#ff2b4a'; ctx.shadowBlur = 8; }
    else { ctx.fillStyle = 'rgba(120,30,45,0.35)'; ctx.shadowBlur = 0; }
    ctx.fillText('♥', hx, y); hx -= 22;
  }
  ctx.shadowBlur = 0; ctx.textAlign = 'left';
}
function drawP2KeyIndicator(x, y){
  const keys = [
    { code:'KeyI', label:'I', alt:['ArrowUp','Numpad8','Digit8'] },
    { code:'KeyJ', label:'J', alt:['ArrowLeft','Numpad4','Digit4'] },
    { code:'KeyK', label:'K', alt:['ArrowDown','Numpad2','Numpad5','Digit2','Digit5'] },
    { code:'KeyL', label:'L', alt:['ArrowRight','Numpad6','Digit6'] }
  ];
  ctx.save();
  ctx.font = 'bold 8px Courier New';
  ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(x-4, y-12, 52, 54);
  ctx.strokeStyle = '#2d3446'; ctx.lineWidth = 1; ctx.strokeRect(x-4, y-12, 52, 54);
  ctx.fillStyle = '#888'; ctx.fillText('P2 КЕЙС', x, y-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 = x + (i % 2) * 24;
    const ky = y + 10 + Math.floor(i / 2) * 18;
    ctx.fillStyle = on ? '#00e5ff' : '#333';
    ctx.fillRect(kx, ky, 20, 14);
    ctx.fillStyle = on ? '#000' : '#666';
    ctx.font = 'bold 11px Courier New'; ctx.fillText(k.label, kx + 7, ky + 11);
  }
  ctx.restore();
}

/* ================= SCREENS ================= */
function drawPressStartScreen(){
  ctx.clearRect(0,0,W,H); ctx.fillStyle = 'rgba(0,0,0,0.88)'; ctx.fillRect(0,0,W,H);
  ctx.textAlign = 'center';
  ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 15px Courier New';
  ctx.fillText(`ROAD FIGHTER ${GAME_VERSION}`, W/2, 180);
  const grad = ctx.createLinearGradient(W/2-250,0,W/2+250,0);
  grad.addColorStop(0,'#ff2b2b'); grad.addColorStop(0.5,'#f1c40f'); grad.addColorStop(1,'#00e5ff');
  ctx.fillStyle = grad; ctx.font = 'bold 52px Courier New';
  ctx.fillText('ROAD FIGHTER', W/2, 245);
  ctx.fillStyle = '#ffd700'; ctx.font = 'bold 17px Courier New';
  ctx.fillText('САЛЬТО = 5с НИТРО 455 км/ч', W/2, 285);
  ctx.fillStyle = '#ff69b4'; ctx.font = 'bold 14px Courier New';
  ctx.fillText('SPLIT-SCREEN: P1 слева, P2 справа', W/2, 315);
  ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 12px Courier New';
  ctx.fillText('Отстающий +10%. Дороги полностью независимы', W/2, 340);
  ctx.fillStyle = '#ff6b00'; ctx.font = 'bold 12px Courier New';
  ctx.fillText('P2 IJKL: I=газ J=влево K=тормоз L=вправо', W/2, 368);
  ctx.fillText('Трюк [O]   Ручник [M]', W/2, 386);
  ctx.fillStyle = '#ff2b4a'; ctx.font = 'bold 14px Courier New';
  ctx.fillText('♥ ♥ ♥  —  3 жизни', W/2, 426);
  ctx.fillStyle = '#ffffff'; ctx.font = 'bold 15px Courier New';
  if (Math.floor(Date.now()/500)%2===0) ctx.fillText('НАЖМИТЕ ЛЮБУЮ КЛАВИШУ', W/2, 468);
  ctx.fillStyle = '#8892a6'; ctx.font = '12px Courier New';
  ctx.fillText('(Y / ESC / M — вход в меню)', W/2, 496);
  ctx.textAlign = 'left';
}

function drawMenuScreen(){
  ctx.clearRect(0,0,W,H); ctx.fillStyle = 'rgba(0,0,0,0.92)'; ctx.fillRect(0,0,W,H);
  ctx.textAlign = 'center';
  const grad = ctx.createLinearGradient(W/2-200,0,W/2+200,0);
  grad.addColorStop(0,'#ff2b2b'); grad.addColorStop(0.5,'#f1c40f'); grad.addColorStop(1,'#00e5ff');
  ctx.fillStyle = grad; ctx.font = 'bold 36px Courier New';
  ctx.fillText('ROAD FIGHTER 3D', W/2, 70);
  ctx.fillStyle = '#00e5ff'; ctx.font = 'bold 11px Courier New';
  ctx.fillText(GAME_VERSION + '  —  ГЛАВНОЕ МЕНЮ', W/2, 92);

  const musicNames = ['[ ВЫКЛЮЧЕНА ]', '[ ТРЕК 1 ]', '[ ТРЕК 2 ]', '[ ТРЕК 3 ]'];
  const livesText = livesEnabled ? `ЖИЗНИ: < ВКЛ (${getLivesLeft()}/${MAX_LIVES} ♥) >` : 'ЖИЗНИ: < ВЫКЛЮЧЕНЫ >';

  const rows = [];
  rows.push({ text: isTwoPlayer ? 'РЕЖИМ: 2 ИГРОКА (SPLIT)' : 'РЕЖИМ: 1 ИГРОК', id: 0 });
  rows.push({ text: livesText, sub: livesEnabled ? '3 смерти → рестарт с 1 уровня' : 'авария — просто респавн', id: 1, livesRow: true });
  rows.push({ text: `P1 АВТО: < ${CAR_CLASSES[selectedCarClassP1].name} >`, sub: CAR_CLASSES[selectedCarClassP1].desc, id: 2 });
  if (isTwoPlayer){
    rows.push({ text: `P2 АВТО: < ${CAR_CLASSES[selectedCarClassP2].name} >`, sub: CAR_CLASSES[selectedCarClassP2].desc, id: 3 });
    rows.push({ text: `МУЗЫКА: < ${musicNames[selectedMusicTrack]} >`, id: 4 });
    rows.push({ text: `СТАРТ (ЭТАП ${currentLevel+1})`, id: 5, isStart: true });
  } else {
    rows.push({ text: `МУЗЫКА: < ${musicNames[selectedMusicTrack]} >`, id: 3 });
    rows.push({ text: `СТАРТ (ЭТАП ${currentLevel+1})`, id: 4, isStart: true });
  }
  let y = 130;
  rows.forEach((r, i) => {
    const sel = menuSelection === i;
    ctx.font = 'bold 16px Courier New';
    if (r.isStart){
      ctx.fillStyle = sel ? '#2ecc71' : '#6b7688';
      ctx.fillText(sel ? `★  ${r.text}  ★` : r.text, W/2, y);
    } else if (r.livesRow){
      ctx.fillStyle = sel ? (livesEnabled ? '#2ecc71' : '#e74c3c') : (livesEnabled ? '#4aa96c' : '#a64b4b');
      ctx.fillText(sel ? `▶  ${r.text}  ◀` : r.text, W/2, y);
      if (r.sub && sel){ ctx.font = '11px Courier New'; ctx.fillStyle = '#00e5ff'; ctx.fillText(r.sub, W/2, y + 17); }
    } else {
      ctx.fillStyle = sel ? '#f1c40f' : '#8892a6';
      ctx.fillText(sel ? `▶  ${r.text}  ◀` : r.text, W/2, y);
      if (r.sub && sel){ ctx.font = '11px Courier New'; ctx.fillStyle = '#00e5ff'; ctx.fillText(r.sub, W/2, y + 17); }
    }
    y += r.sub ? 46 : 38;
  });

  if (isTwoPlayer){
    ctx.font = 'bold 11px Courier New'; ctx.fillStyle = '#2ecc71';
    ctx.fillText('Слева — P1, справа — P2. Отстающий +10%.', W/2, H - 72);
  }
  ctx.font = '11px Courier New'; ctx.fillStyle = '#6b7688';
  ctx.fillText('↑/↓ — строки  |  ←/→ — изменить  |  ENTER — старт', W/2, H-44);
  ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 12px Courier New';
  ctx.fillText('Ъ — мгновенный рестарт с 1-го уровня', W/2, H-20);
  ctx.textAlign = 'left';
}

function drawGameOverMenu(){
  ctx.clearRect(0,0,W,H); ctx.fillStyle = 'rgba(0,0,0,0.90)'; ctx.fillRect(0,0,W,H);
  ctx.textAlign = 'center';
  ctx.fillStyle = '#e74c3c'; ctx.font = 'bold 32px Courier New';
  ctx.fillText('ВРЕМЯ ВЫШЛО!', W/2, 150);
  ctx.fillStyle = '#ecf0f1'; ctx.font = '14px Courier New';
  ctx.fillText(`ЭТАП: [ ${STAGES[currentLevel].name} ]`, W/2, 190);
  ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 16px Courier New';
  ctx.fillText(`ОЧКИ: ${players[0] ? players[0].score : 0}`, W/2, 222);
  if (livesEnabled){
    ctx.fillStyle = '#8892a6'; ctx.font = 'bold 12px Courier New';
    ctx.fillText('ОСТАЛОСЬ ЖИЗНЕЙ:', W/2, 254);
    drawHearts(W/2 + 55, 274, getLivesLeft());
  }
  const o1 = 'ПРОДОЛЖИТЬ С ТЕКУЩЕГО УРОВНЯ';
  const o2 = 'НАЧАТЬ ВСЁ С 1 УРОВНЯ';
  ctx.font = 'bold 15px Courier New';
  ctx.fillStyle = gameOverSelection === 0 ? '#f1c40f' : '#7f8c8d';
  ctx.fillText(gameOverSelection === 0 ? `★  ${o1}  ★` : o1, W/2, 340);
  ctx.fillStyle = gameOverSelection === 1 ? '#f1c40f' : '#7f8c8d';
  ctx.fillText(gameOverSelection === 1 ? `★  ${o2}  ★` : o2, W/2, 390);
  ctx.font = '11px Courier New'; ctx.fillStyle = '#8892a6';
  ctx.fillText('СТРЕЛКИ ВВЕРХ/ВНИЗ и ENTER', W/2, 450);
  ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 12px Courier New';
  ctx.fillText('Ъ — мгновенный рестарт с 1-го уровня', W/2, H-25);
  ctx.textAlign = 'left';
}

function drawAllLivesLostScreen(){
  ctx.clearRect(0,0,W,H); ctx.fillStyle = 'rgba(0,0,0,0.92)'; ctx.fillRect(0,0,W,H);
  const pulse = 0.5 + Math.sin(Date.now()/220)*0.35;
  ctx.strokeStyle = `rgba(255,30,40,${0.5+pulse*0.5})`; ctx.lineWidth = 6;
  ctx.strokeRect(8, 8, W-16, H-16);
  ctx.textAlign = 'center';
  ctx.fillStyle = '#e74c3c'; ctx.font = 'bold 60px Courier New';
  ctx.fillText('☠  ☠  ☠', W/2, 130);
  ctx.fillStyle = '#e74c3c'; ctx.font = 'bold 34px Courier New';
  ctx.fillText('ВСЕ ЖИЗНИ ПОТЕРЯНЫ!', W/2, 195);
  ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 20px Courier New';
  ctx.fillText('СТАРТ С 1-ГО УРОВНЯ', W/2, 250);
  ctx.fillStyle = 'rgba(5,8,15,0.85)'; ctx.fillRect(W/2-220, 290, 440, 90);
  ctx.strokeStyle = 'rgba(255,43,74,0.5)'; ctx.lineWidth = 2; ctx.strokeRect(W/2-220, 290, 440, 90);
  ctx.fillStyle = '#ecf0f1'; ctx.font = '13px Courier New';
  ctx.fillText(`ЭТАП ПРЕРВАН: [ ${STAGES[currentLevel].name} ]`, W/2, 318);
  ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 16px Courier New';
  ctx.fillText(`ОЧКИ: ${players[0] ? players[0].score : 0}`, W/2, 350);
  ctx.fillStyle = '#ff2b4a'; ctx.font = 'bold 14px Courier New';
  ctx.fillText('♥ ♥ ♥  →  ♡ ♡ ♡', W/2, 405);
  ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 20px Courier New';
  if (Math.floor(Date.now()/500)%2===0) ctx.fillText('▶  НАЖМИ  ENTER  ЧТОБЫ  НАЧАТЬ  ЗАНОВО  ◀', W/2, 480);
  ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 12px Courier New';
  ctx.fillText('или Ъ — тоже рестарт с 1-го уровня', W/2, 520);
  ctx.textAlign = 'left';
}

function drawOverlay(title, subtitle, color){
  ctx.clearRect(0,0,W,H); ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(0,0,W,H);
  ctx.textAlign = 'center';
  ctx.font = 'bold 28px Courier New'; ctx.fillStyle = color;
  ctx.fillText(title, W/2, H/2-20);
  ctx.font = '13px Courier New'; ctx.fillStyle = '#fff';
  ctx.fillText(subtitle, W/2, H/2+26);
  ctx.fillStyle = '#2ecc71'; ctx.font = 'bold 12px Courier New';
  ctx.fillText('или нажми Ъ — рестарт с 1-го уровня', W/2, H/2+60);
  ctx.textAlign = 'left';
}

/* ================= MAIN LOOP ================= */
let lastTime = performance.now();
function gameLoop(time){
  const dt = Math.min((time - lastTime) / 1000, 0.1);
  lastTime = time;

  update(dt);
  syncPlayerMeshes(dt);

  if (isTwoPlayer && players.length === 2){
    updateCameraFor(players[0], camP1, dt);
    updateCameraFor(players[1], camP2, dt);
  } else if (players[0]){
    updateCameraFor(players[0], camP1, dt);
  }

  renderScene();

  if (gameState === 'PRESS_START') drawPressStartScreen();
  else if (gameState === 'MENU') drawMenuScreen();
  else if (gameState === 'STAGE_CLEAR'){ drawHUD(); drawOverlay('ЭТАП ПРОЙДЕН!', 'ЧЕКПОИНТ СОХРАНЁН — [ENTER]', '#2ecc71'); }
  else if (gameState === 'GAMEOVER'){ drawHUD(); drawGameOverMenu(); }
  else if (gameState === 'ALL_LIVES_LOST'){ drawHUD(); drawAllLivesLostScreen(); }
  else if (gameState === 'VICTORY'){ drawHUD(); drawOverlay('ЧЕМПИОН ВСЕХ ТРАСС!', 'ИГРА ПРОЙДЕНА — [ENTER]', '#f1c40f'); }
  else drawHUD();

  requestAnimationFrame(gameLoop);
}

loadStage(currentLevel);
requestAnimationFrame(gameLoop);
</script>
</body>
</html>

Game Source: Road Fighter 3D — Independent Split Worlds

Creator: RocketKoala70

Libraries: three

Complexity: complex (1961 lines, 93.0 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-3d-independent-split-worlds-rocketkoala70" to link back to the original. Then publish at arcadelab.ai/publish.