summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpacan-bot <[email protected]>2026-07-11 02:46:59 +0000
committerpacan-bot <[email protected]>2026-07-11 02:46:59 +0000
commitae6d6689eec02b0328eb5e7b1f1f48c523268a8f (patch)
treecd4c79cd53ae8449a1cab8fb39f796b383509938
parent499266fb5d1da142450280575702153740e14cfb (diff)
downloadplaneta-zhopa-ae6d6689eec02b0328eb5e7b1f1f48c523268a8f.tar.gz
planeta-zhopa-ae6d6689eec02b0328eb5e7b1f1f48c523268a8f.zip
машина: физика reVC (выбор дрон/машина) + фикс руля, скорости и фары
- руль: влево/вправо больше не перепутаны (инвертирован знак A/D) - скорость: БРОВЕНОСЕЦ 99->120 км/ч и вдвое живее разгон (engineAccel 16->34, maxVel 200->270); ЖОПЕРРАРИ 40->63 км/ч (engineAccel 18->32, maxVel 210->300, traction 1.15->1.30, меньше буксует на грунте) - фары: две area-light (RectAreaLight/LTC, как у роторов дрона) + светящиеся линзы, светят вперёд и чуть вниз на дорогу - в репозиторий добавлены исходники физики: serve/car_phys.js, serve/_carmod.js
-rw-r--r--serve/_carmod.js128
-rw-r--r--serve/car_phys.js805
-rw-r--r--serve/index.html944
3 files changed, 1875 insertions, 2 deletions
diff --git a/serve/_carmod.js b/serve/_carmod.js
new file mode 100644
index 0000000..da70fd3
--- /dev/null
+++ b/serve/_carmod.js
@@ -0,0 +1,128 @@
+/* ===== АВТО-РЕЖИМ: выбор транспорта + езда на ported reVC-физике (client) ===== */
+var carMode = false, carObj = null, carMesh = null, carGround = null, carAcc = 0, carPreset = 'brovenosec';
+var carMesh_wheels = [];
+var carHeadlights = [];
+var CAR_SPAWN = { x: 70, z: -40 };
+
+function buildCarMesh() {
+ var h = carObj.h;
+ var g = new THREE.Group();
+ var L = h.Dimension.y, Wd = h.Dimension.x, Ht = h.Dimension.z;
+ var body = new THREE.Mesh(new THREE.BoxGeometry(Wd, Ht * 0.6, L),
+ new THREE.MeshStandardMaterial({ color: 0xc81e3a, roughness: 0.45, metalness: 0.3 }));
+ body.position.y = Ht * 0.15; g.add(body);
+ var cab = new THREE.Mesh(new THREE.BoxGeometry(Wd * 0.86, Ht * 0.5, L * 0.5),
+ new THREE.MeshStandardMaterial({ color: 0x20242c, roughness: 0.3, metalness: 0.4 }));
+ cab.position.set(0, Ht * 0.55, -L * 0.05); g.add(cab);
+ var nose = new THREE.Mesh(new THREE.BoxGeometry(Wd * 0.5, Ht * 0.2, 0.3),
+ new THREE.MeshStandardMaterial({ color: 0xffd23a, emissive: 0x442200 }));
+ nose.position.set(0, Ht * 0.1, L * 0.5); g.add(nose); // жёлтый нос = вперёд (+Z)
+ // фары — area lights (LTC, как у дрона): светят вперёд (+Z) и чуть вниз на дорогу
+ var hlX = Wd * 0.34, hlY = Ht * 0.16, hlZ = L * 0.5 + 0.04;
+ var lensMat = new THREE.MeshStandardMaterial({ color: 0xfff6d8, emissive: 0xfff0b0, emissiveIntensity: 2.6, roughness: 0.25, metalness: 0.0 });
+ carHeadlights = [];
+ for (var hi = 0; hi < 2; hi++) {
+ var sx = hi === 0 ? -1 : 1;
+ var lens = new THREE.Mesh(new THREE.BoxGeometry(Wd * 0.26, Ht * 0.18, 0.10), lensMat);
+ lens.position.set(sx * hlX, hlY, hlZ); g.add(lens);
+ var hl = new THREE.RectAreaLight(0xfff2cc, 11.0, Wd * 0.55, Ht * 0.42);
+ hl.position.set(sx * hlX, hlY, hlZ + 0.06);
+ hl.rotation.set(Math.PI + 0.16, 0, 0); // локальный -Z -> +Z (вперёд), лёгкий наклон вниз
+ g.add(hl); carHeadlights.push(hl);
+ }
+ var wr = carObj.wheelRadius;
+ var wheelGeo = new THREE.CylinderGeometry(wr, wr, 0.3, 16);
+ var wheelMat = new THREE.MeshStandardMaterial({ color: 0x111214, roughness: 0.85 });
+ carMesh_wheels = [];
+ for (var i = 0; i < 4; i++) {
+ var w = new THREE.Mesh(wheelGeo, wheelMat);
+ var pb = carObj.m_wheelPosBody[i];
+ w.position.set(pb.x, pb.y, pb.z);
+ w.rotation.set(0, 0, Math.PI / 2); // ось колеса вдоль X
+ g.add(w); carMesh_wheels.push(w);
+ }
+ return g;
+}
+
+function spawnCar() {
+ var CP = window.PZCarPhys;
+ var hd = CP.makeHandling(CP.PRESETS[carPreset]);
+ carGround = CP.makeGround(groundH, WATER_Y, { patch: 1.5 });
+ var gy = groundH(CAR_SPAWN.x, CAR_SPAWN.z);
+ carObj = new CP.Car(hd, { x: CAR_SPAWN.x, y: gy + 2.0, z: CAR_SPAWN.z, heading: 0 });
+ carMesh = buildCarMesh();
+ scene.add(carMesh);
+ try { window.PZCAR = carObj; } catch(e){}
+}
+
+function readCarInput() {
+ var th = 0, st = 0;
+ if (keys['KeyW'] || keys['ArrowUp']) th += 1;
+ if (keys['KeyS'] || keys['ArrowDown']) th -= 1;
+ if (keys['KeyA'] || keys['ArrowLeft']) st += 1; // руль влево (исправлено: влево/вправо было перепутано)
+ if (keys['KeyD'] || keys['ArrowRight']) st -= 1; // руль вправо
+ return { throttle: th, steer: st, handbrake: !!keys['Space'], brakeHeld: (th < 0) };
+}
+
+var _cbR = new THREE.Vector3(), _cbU = new THREE.Vector3(), _cbF = new THREE.Vector3(), _cbM = new THREE.Matrix4();
+function carRespawn() {
+ var CP = window.PZCarPhys, gy = groundH(CAR_SPAWN.x, CAR_SPAWN.z);
+ carObj.pos = CP._v(CAR_SPAWN.x, gy + 2.0, CAR_SPAWN.z);
+ carObj.moveSpeed = CP._v(0, 0, 0); carObj.turnSpeed = CP._v(0, 0, 0);
+ carObj.setHeading(0);
+}
+function carStep(dt) {
+ if (!carObj) return;
+ if (keys['KeyR']) carRespawn();
+ var inp = readCarInput(); carAcc += dt; var n = 0;
+ while (carAcc >= 0.02 && n < 6) { carObj.step(inp, carGround); carAcc -= 0.02; n++; } // фиксированные 50 Гц
+ carMesh.position.set(carObj.pos.x, carObj.pos.y, carObj.pos.z);
+ _cbR.set(carObj.right.x, carObj.right.y, carObj.right.z);
+ _cbU.set(carObj.up.x, carObj.up.y, carObj.up.z);
+ _cbF.set(carObj.fwd.x, carObj.fwd.y, carObj.fwd.z);
+ _cbM.makeBasis(_cbR, _cbU, _cbF); carMesh.quaternion.setFromRotationMatrix(_cbM);
+ for (var i = 0; i < 4; i++) {
+ var w = carMesh_wheels[i]; if (!w) continue;
+ var pb = carObj.m_wheelPosBody[i]; w.position.set(pb.x, pb.y, pb.z);
+ var steer = (i < 2) ? carObj.m_fSteerAngle : 0; // FL,FR поворачиваются
+ w.rotation.set(0, -steer, Math.PI / 2);
+ }
+}
+
+var _carGoal = new THREE.Vector3(), _carLook = new THREE.Vector3();
+function carCamera() {
+ controls.enabled = false;
+ _cbF.set(carObj.fwd.x, 0, carObj.fwd.z); if (_cbF.lengthSq() < 1e-4) _cbF.set(0, 0, 1); _cbF.normalize();
+ _carGoal.set(carObj.pos.x, carObj.pos.y, carObj.pos.z).addScaledVector(_cbF, -12).addScaledVector(WUP, 5.5);
+ camera.position.lerp(_carGoal, 0.12);
+ _carLook.set(carObj.pos.x, carObj.pos.y + 1.3, carObj.pos.z);
+ _camLook.lerp(_carLook, 0.3); camera.lookAt(_camLook);
+}
+
+/* --- стартовый выбор транспорта --- */
+(function vehicleSelect() {
+ var ov = document.createElement('div');
+ ov.id = 'vsOverlay';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:10050;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(4,6,12,.88);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);font-family:system-ui,Segoe UI,Roboto,sans-serif;color:#eef3ff';
+ ov.innerHTML =
+ '<div style="font:800 30px system-ui;margin-bottom:6px;text-align:center;letter-spacing:1px">ПЛАНЕТА ЖОПА</div>' +
+ '<div style="opacity:.85;margin-bottom:24px">На чём поедешь?</div>' +
+ '<div style="display:flex;gap:18px;flex-wrap:wrap;justify-content:center">' +
+ '<button id="vsDrone" style="cursor:pointer;border:0;border-radius:16px;padding:24px 28px;min-width:190px;line-height:1.35;background:linear-gradient(180deg,#4aa3ff,#2166c8);color:#fff;font:800 20px system-ui;box-shadow:0 12px 40px rgba(0,0,0,.5)">🚁<br>ДРОН<br><span style="font:500 12px system-ui;opacity:.85">полёт · акро · WASD</span></button>' +
+ '<button id="vsCar" style="cursor:pointer;border:0;border-radius:16px;padding:24px 28px;min-width:190px;line-height:1.35;background:linear-gradient(180deg,#ff6a4a,#c8291e);color:#fff;font:800 20px system-ui;box-shadow:0 12px 40px rgba(0,0,0,.5)">🚗<br>МАШИНА<br><span style="font:500 12px system-ui;opacity:.85">физика reVC · газ/руль</span></button>' +
+ '</div>' +
+ '<div style="margin-top:22px;opacity:.6;font-size:12px">сменить транспорт — перезагрузка страницы</div>';
+ document.body.appendChild(ov);
+ ['pointerdown', 'touchstart', 'mousedown', 'keydown'].forEach(function (ev) { ov.addEventListener(ev, function (e) { e.stopPropagation(); }); });
+ document.getElementById('vsDrone').onclick = function () { carMode = false; ov.remove(); };
+ document.getElementById('vsCar').onclick = function () {
+ carMode = true;
+ try { spawnCar(); } catch (e) { console.error('spawnCar', e); }
+ try { if (typeof drone !== 'undefined' && drone) drone.visible = false; } catch (e) {}
+ try { sd.style.display = 'none'; } catch (e) {}
+ try { dp.style.display = 'none'; } catch (e) {}
+ var hp = document.getElementById('help');
+ if (hp) hp.innerHTML = '<b>🚗 МАШИНА (физика reVC):</b> <kbd>W</kbd> газ · <kbd>S</kbd> тормоз/задний · <kbd>A</kbd>/<kbd>D</kbd> руль · <kbd>Space</kbd> ручник · <kbd>R</kbd> сброс';
+ ov.remove();
+ };
+})();
diff --git a/serve/car_phys.js b/serve/car_phys.js
new file mode 100644
index 0000000..087e7bc
--- /dev/null
+++ b/serve/car_phys.js
@@ -0,0 +1,805 @@
+/* =====================================================================
+ Планета Жопа — АВТОФИЗИКА
+ Faithful JS port of the GTA:VC (reVC) car handling model.
+ Ported 1:1 from kirillsurkov/racing_game (reVC "miami"):
+ - cTransmission::InitGearRatios / CalculateDriveAcceleration (Transmission.cpp)
+ - cHandlingDataMgr::ConvertDataToGameUnits (HandlingMgr.cpp)
+ - CVehicle::ProcessWheel (tyre traction/skid model) (Vehicle.cpp)
+ - CAutomobile::ProcessControl driving core (susp+drive+brake+steer)
+ - CPhysical rigid body: ApplyMoveForce/ApplyTurnForce/GetMass,
+ ApplySpringCollisionAlt, ApplySpringDampening, ApplyAirResistance,
+ ApplyGravity, ApplyMoveSpeed, ApplyTurnSpeed (Physical.cpp)
+ Units: 1 unit = 1 m, 1 step = 1/50 s (GetTimeStep()==1.0). Runs at fixed 50 Hz.
+ Coordinate frame adapted to the game's world: Y up (VC uses Z up).
+ Works in node (plain {x,y,z}) and in-browser (same code).
+ ===================================================================== */
+(function (root) {
+ 'use strict';
+
+ // ---- constants (from reVC) ----
+ var GRAVITY = 0.008; // Physical.h #define GRAVITY (0.008f)
+ var WHEEL_FRICTION = 0.9; // cHandlingDataMgr::Initialise fWheelFriction
+ var STEP = 1.0; // CTimer::GetTimeStep() nominal (50 Hz)
+ // wheel indices (CARWHEEL_*)
+ var FL = 0, FR = 1, RL = 2, RR = 3;
+ // tWheelState
+ var WHEEL_STATE_NORMAL = 0, WHEEL_STATE_SPINNING = 1, WHEEL_STATE_SKIDDING = 2, WHEEL_STATE_FIXED = 3;
+
+ // ---- tiny vec3 on {x,y,z} ----
+ function v(x, y, z) { return { x: x || 0, y: y || 0, z: z || 0 }; }
+ function vset(a, x, y, z) { a.x = x; a.y = y; a.z = z; return a; }
+ function vcopy(a) { return { x: a.x, y: a.y, z: a.z }; }
+ function vadd(a, b) { return v(a.x + b.x, a.y + b.y, a.z + b.z); }
+ function vsub(a, b) { return v(a.x - b.x, a.y - b.y, a.z - b.z); }
+ function vscale(a, s) { return v(a.x * s, a.y * s, a.z * s); }
+ function vaddi(a, b) { a.x += b.x; a.y += b.y; a.z += b.z; return a; }
+ function vaddscaled(a, b, s) { a.x += b.x * s; a.y += b.y * s; a.z += b.z * s; return a; }
+ function dot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
+ function cross(a, b) {
+ return v(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
+ }
+ function magsq(a) { return a.x * a.x + a.y * a.y + a.z * a.z; }
+ function mag(a) { return Math.sqrt(magsq(a)); }
+ function norm(a) { var m = mag(a); if (m > 1e-9) { return v(a.x / m, a.y / m, a.z / m); } return v(0, 0, 0); }
+ function sq(x) { return x * x; }
+ function DEGTORAD(d) { return d * Math.PI / 180; }
+
+ // =====================================================================
+ // Handling: raw cfg fields -> game units (ConvertDataToGameUnits 1:1)
+ // =====================================================================
+ // raw fields mirror HANDLING.CFG columns (post the *0.4 that LoadHandlingData
+ // applies to engineAccel at field 14).
+ function makeHandling(raw) {
+ var h = {
+ name: raw.name,
+ fMass: raw.mass,
+ Dimension: v(raw.dimX, raw.dimY, raw.dimZ),
+ CentreOfMass: v(raw.comX, raw.comY, raw.comZ),
+ nPercentSubmerged: raw.submerged,
+ fTractionMultiplier: raw.tractionMult,
+ fTractionLoss: raw.tractionLoss,
+ fTractionBias: raw.tractionBias,
+ fBrakeDeceleration: raw.brakeDecel,
+ fBrakeBias: raw.brakeBias,
+ bABS: raw.abs,
+ fSteeringLock: raw.steerLock,
+ fSuspensionForceLevel: raw.susForce,
+ fSuspensionDampingLevel: raw.susDamp,
+ fSuspensionUpperLimit: raw.susUpper,
+ fSuspensionLowerLimit: raw.susLower,
+ fSuspensionBias: raw.susBias,
+ fSuspensionAntidiveMultiplier: raw.susAntidive || 0,
+ fCollisionDamageMultiplier: raw.collDmg || 1,
+ Flags: raw.flags || 0,
+ Transmission: {
+ nNumberOfGears: raw.nGears,
+ nDriveType: raw.driveType, // 'F' | 'R' | '4'
+ nEngineType: raw.engineType || 'P',
+ Flags: raw.flags || 0,
+ fEngineAcceleration: raw.engineAccel * 0.4, // LoadHandlingData field 14: *0.4
+ fMaxVelocity: raw.maxVel,
+ Gears: [ {}, {}, {}, {}, {}, {} ]
+ }
+ };
+ convertToGameUnits(h);
+ initGearRatios(h.Transmission);
+ // moment of inertia already set by convert
+ return h;
+ }
+
+ function convertToGameUnits(h) {
+ var T = h.Transmission;
+ T.fEngineAcceleration *= 1.0 / (50.0 * 50.0);
+ T.fMaxVelocity *= 1000.0 / (60.0 * 60.0 * 50.0);
+ h.fBrakeDeceleration *= 1.0 / (50.0 * 50.0);
+ h.fTurnMass = (sq(h.Dimension.x) + sq(h.Dimension.y)) * h.fMass / 12.0;
+ if (h.fTurnMass < 10.0) h.fTurnMass *= 5.0;
+ h.fInvMass = 1.0 / h.fMass;
+ h.fBuoyancy = 100.0 / h.nPercentSubmerged * GRAVITY * h.fMass;
+
+ // drag-limited real max velocity (ConvertDataToGameUnits 1:1)
+ var a = 0.0, b = 100.0, velocity = T.fMaxVelocity;
+ while (a < b && velocity > 0.0) {
+ velocity -= 0.01;
+ a = T.fEngineAcceleration / 6.0;
+ var a_drag = 0.5 * sq(velocity) * h.Dimension.x * h.Dimension.z / h.fMass;
+ b = -velocity * (1.0 / (a_drag + 1.0) - 1.0);
+ }
+ T.fMaxCruiseVelocity = velocity;
+ T.fMaxVelocity = velocity * 1.2;
+ T.fMaxReverseVelocity = -0.2;
+
+ if (T.nDriveType === '4') T.fEngineAcceleration /= 4.0;
+ else T.fEngineAcceleration /= 2.0;
+ }
+
+ function initGearRatios(T) {
+ var G = T.Gears;
+ var i;
+ for (i = 0; i < 6; i++) G[i] = { fMaxVelocity: 0, fShiftUpVelocity: 0, fShiftDownVelocity: 0 };
+ for (i = 1; i <= T.nNumberOfGears; i++) {
+ var g0 = G[i - 1], g1 = G[i];
+ g1.fMaxVelocity = i / T.nNumberOfGears * T.fMaxVelocity;
+ var velocityDiff = g1.fMaxVelocity - g0.fMaxVelocity;
+ if (i >= T.nNumberOfGears) {
+ g1.fShiftUpVelocity = T.fMaxVelocity;
+ } else {
+ G[i + 1].fShiftDownVelocity = velocityDiff * 0.42 + g0.fMaxVelocity;
+ g1.fShiftUpVelocity = velocityDiff * 0.6667 + g0.fMaxVelocity;
+ }
+ }
+ G[0].fMaxVelocity = T.fMaxReverseVelocity;
+ G[0].fShiftUpVelocity = -0.01;
+ G[0].fShiftDownVelocity = T.fMaxReverseVelocity;
+ G[1].fShiftDownVelocity = -0.01;
+ }
+
+ var HANDLING_2G_BOOST = 2, HANDLING_1G_BOOST = 1;
+
+ // cTransmission::CalculateDriveAcceleration (recursive gear selection) 1:1
+ function calcDriveAccel(T, gasPedal, gearRef, velocity, cheat) {
+ var fVelocity = velocity;
+ if (fVelocity < T.fMaxReverseVelocity) { return 0.0; }
+ if (fVelocity > T.fMaxVelocity) { return 0.0; }
+
+ var gear = gearRef.gear;
+ var pGearRatio = T.Gears[gear];
+ if (fVelocity > pGearRatio.fShiftUpVelocity) {
+ if (gear !== 0 || gasPedal > 0.0) {
+ gearRef.gear = gear + 1;
+ return calcDriveAccel(T, gasPedal, gearRef, fVelocity, false);
+ }
+ } else if (fVelocity < pGearRatio.fShiftDownVelocity && gear !== 0) {
+ if (gear !== 1 || gasPedal < 0.0) {
+ gearRef.gear = gear - 1;
+ return calcDriveAccel(T, gasPedal, gearRef, fVelocity, false);
+ }
+ }
+
+ var speedMul, accelMul;
+ var Flags = T.Flags;
+ if (gear < 1) {
+ accelMul = (Flags & HANDLING_2G_BOOST) ? 2.0 : 1.0;
+ speedMul = -1.0;
+ } else if (T.nNumberOfGears === 1) {
+ accelMul = 1.0; speedMul = 1.0;
+ } else {
+ var f = 1.0 - (gear - 1) / (T.nNumberOfGears - 1);
+ speedMul = 3.0 * sq(f) + 1.0;
+ if (Flags & HANDLING_2G_BOOST) {
+ if (gear === 1) accelMul = (Flags & HANDLING_1G_BOOST) ? 2.0 : 1.6;
+ else if (gear === 2) accelMul = 1.3;
+ else accelMul = 1.0;
+ } else if ((Flags & HANDLING_1G_BOOST) && gear === 1) {
+ accelMul = 2.0;
+ } else accelMul = 1.0;
+ }
+
+ var fCheat = cheat ? 1.2 : 1.0;
+ var targetVelocity = T.Gears[gear].fMaxVelocity * speedMul * fCheat;
+ var accel = (targetVelocity - fVelocity) * (T.fEngineAcceleration * accelMul) / Math.abs(targetVelocity);
+ var fAcceleration;
+ if (Math.abs(fVelocity) < Math.abs(T.Gears[gear].fMaxVelocity * fCheat))
+ fAcceleration = gasPedal * accel * STEP;
+ else
+ fAcceleration = 0.0;
+ return fAcceleration;
+ }
+
+ // =====================================================================
+ // Car rigid body
+ // =====================================================================
+ function Car(handling, opts) {
+ opts = opts || {};
+ this.h = handling;
+ this.m_fMass = handling.fMass;
+ this.m_fTurnMass = handling.fTurnMass;
+ this.m_vecCentreOfMass = vcopy(handling.CentreOfMass);
+ this.m_fAirResistance = handling.Dimension.x * handling.Dimension.z / handling.fMass; // CAutomobile ctor
+
+ // pose: position + orthonormal basis (right,up,fwd)
+ this.pos = v(opts.x || 0, opts.y || 0, opts.z || 0);
+ this.right = v(1, 0, 0);
+ this.up = v(0, 1, 0);
+ this.fwd = v(0, 0, 1);
+ if (opts.heading != null) this.setHeading(opts.heading);
+
+ // velocities
+ this.moveSpeed = v(0, 0, 0); // m_vecMoveSpeed (units/step)
+ this.turnSpeed = v(0, 0, 0); // m_vecTurnSpeed (rad/step, world)
+
+ // controls
+ this.m_fGasPedal = 0;
+ this.m_fBrakePedal = 0;
+ this.m_fSteerAngle = 0;
+ this.m_fSteerInput = 0;
+ this.bIsHandbrakeOn = false;
+ this.m_doingBurnout = 0;
+
+ // transmission state
+ this.gearRef = { gear: 1 };
+ this.m_fTireTemperature = 1.0;
+
+ // per-wheel state
+ var i;
+ this.m_aSuspensionSpringRatio = [1, 1, 1, 1];
+ this.m_aSuspensionSpringRatioPrev = [1, 1, 1, 1];
+ this.m_aWheelTimer = [0, 0, 0, 0];
+ this.m_aWheelSpeed = [0, 0, 0, 0]; // wheel angular speed (visual)
+ this.m_aWheelRotation = [0, 0, 0, 0]; // accumulated wheel angle (visual)
+ this.m_aWheelState = [0, 0, 0, 0];
+ this.m_wheelContactPoint = [v(), v(), v(), v()]; // world, relative to pos
+ this.m_wheelContactNormal = [v(0, 1, 0), v(0, 1, 0), v(0, 1, 0), v(0, 1, 0)];
+ this.m_wheelOnGround = [false, false, false, false];
+
+ this.m_nWheelsOnGround = 0;
+ this.m_nDriveWheelsOnGround = 0;
+
+ // --- suspension geometry (per wheel, body space) ---
+ // Wheel mount X (side): +right = right side. Y(height): mount above hub. Z(fwd): fore/aft.
+ var d = handling.Dimension;
+ var halfW = d.x * 0.5; // track half-width
+ var halfL = d.y * 0.42; // wheelbase half-length
+ this.wheelRadius = opts.wheelRadius || Math.max(0.32, d.z * 0.22);
+ var Ls = handling.fSuspensionUpperLimit - handling.fSuspensionLowerLimit; // spring travel
+ if (Ls < 0.05) Ls = 0.30;
+ this.m_suspSpringLength = Ls;
+ // total ray length = spring travel + wheel radius (VC: lineLength = springLength + radius)
+ this.m_suspLineLength = Ls + this.wheelRadius;
+ // mount height above body origin so hub sits ~ at origin
+ var mountY = handling.fSuspensionUpperLimit;
+ // wheel body positions: [FL, FR, RL, RR]; +Z is forward, +X is right
+ this.m_wheelPosBody = [
+ v(-halfW, mountY, halfL), // FRONT_LEFT
+ v( halfW, mountY, halfL), // FRONT_RIGHT
+ v(-halfW, mountY, -halfL), // REAR_LEFT
+ v( halfW, mountY, -halfL) // REAR_RIGHT
+ ];
+ // spring direction in body space = straight down (-up)
+ this.m_springDirBody = v(0, -1, 0);
+ // height above road (approx, VC m_fHeightAboveRoad)
+ this.m_fHeightAboveRoad = Ls * (1.0 - 1.0 / (4.0 * handling.fSuspensionForceLevel)) - handling.fSuspensionLowerLimit;
+ }
+
+ Car.prototype.setHeading = function (rad) {
+ // rotate basis about world-Y so fwd points to heading (0 => +Z)
+ var c = Math.cos(rad), s = Math.sin(rad);
+ this.fwd = v(s, 0, c);
+ this.up = v(0, 1, 0);
+ this.right = norm(cross(this.up, this.fwd));
+ };
+
+ Car.prototype.heading = function () { return Math.atan2(this.fwd.x, this.fwd.z); };
+
+ // world position of a body-space point
+ Car.prototype.toWorldDir = function (b) {
+ return v(
+ this.right.x * b.x + this.up.x * b.y + this.fwd.x * b.z,
+ this.right.y * b.x + this.up.y * b.y + this.fwd.y * b.z,
+ this.right.z * b.x + this.up.z * b.y + this.fwd.z * b.z
+ );
+ };
+
+ // CPhysical::GetSpeed(r): velocity of a point r (relative to pos)
+ Car.prototype.getSpeed = function (r) {
+ return vadd(this.moveSpeed, cross(this.turnSpeed, r));
+ };
+
+ // CPhysical::ApplyMoveForce
+ Car.prototype.applyMoveForce = function (f) {
+ vaddi(this.moveSpeed, vscale(f, 1.0 / this.m_fMass));
+ };
+ // CPhysical::ApplyTurnForce (impulse j at point p, relative to pos)
+ Car.prototype.applyTurnForce = function (j, p) {
+ var com = this.toWorldDir(this.m_vecCentreOfMass);
+ var turnimpulse = cross(vsub(p, com), j);
+ vaddi(this.turnSpeed, vscale(turnimpulse, 1.0 / this.m_fTurnMass));
+ };
+ // CPhysical::GetMass(pos,dir)
+ Car.prototype.getMass = function (pos, dir) {
+ return 1.0 / (magsq(cross(pos, dir)) / this.m_fTurnMass + 1.0 / this.m_fMass);
+ };
+
+ Car.prototype.applyGravity = function () {
+ this.moveSpeed.y -= GRAVITY * STEP;
+ };
+
+ // CPhysical::ApplyAirResistance
+ Car.prototype.applyAirResistance = function () {
+ if (this.m_fAirResistance > 0.1) {
+ var f = Math.pow(this.m_fAirResistance, STEP);
+ this.moveSpeed = vscale(this.moveSpeed, f);
+ this.turnSpeed = vscale(this.turnSpeed, f);
+ } else {
+ var f2 = Math.pow(1.0 / Math.abs(1.0 + this.m_fAirResistance * 0.5 * magsq(this.moveSpeed)), STEP);
+ this.moveSpeed = vscale(this.moveSpeed, f2);
+ this.turnSpeed = vscale(this.turnSpeed, 0.99);
+ }
+ };
+
+ // CPhysical::ApplySpringCollisionAlt
+ Car.prototype.applySpringCollisionAlt = function (springConst, springDir, point, springRatio, bias, forceDir) {
+ var compression = 1.0 - springRatio;
+ if (compression > 0.0) {
+ var fd = vcopy(forceDir);
+ if (dot(springDir, fd) > 0.0) fd = vscale(fd, -1.0);
+ var step = Math.min(STEP, 3.0);
+ var impulse = GRAVITY * this.m_fMass * step * springConst * compression * bias * 2.0;
+ this.applyMoveForce(vscale(fd, impulse));
+ this.applyTurnForce(vscale(fd, impulse), point);
+ return fd; // return possibly-flipped force dir (VC mutates forceDir; used as springDir below)
+ }
+ return forceDir;
+ };
+
+ // CPhysical::ApplySpringDampening
+ Car.prototype.applySpringDampening = function (damping, springDir, point, speed) {
+ var speedA = dot(speed, springDir);
+ var gs = this.getSpeed(point);
+ var speedB = dot(gs, springDir);
+ if (speedB === 0.0) return;
+ var step = Math.min(STEP, 3.0);
+ var impulse = -damping * (speedA + speedB) / 2.0 * this.m_fMass * step * 0.53;
+ var a = this.m_fTurnMass / ((magsq(point) + 1.0) * 2.0 * this.m_fMass);
+ a = Math.min(a, 1.0);
+ var b = Math.abs(impulse / (speedB * this.m_fMass));
+ if (a < b) impulse *= a / b;
+ this.applyMoveForce(vscale(springDir, impulse));
+ this.applyTurnForce(vscale(springDir, impulse), point);
+ };
+
+ // CVehicle::ProcessWheel (tyre traction/skid) 1:1
+ Car.prototype.processWheel = function (wheelFwd, wheelRight, wheelContactSpeed, wheelContactPoint,
+ wheelsOnGround, thrust, brake, adhesion, wheelId, wsRef) {
+ var bAlreadySkidding = false;
+ var fwd = 0.0, right = 0.0;
+
+ var bBraking = brake !== 0.0;
+ if (bBraking) thrust = 0.0;
+ var bDriving = thrust !== 0.0;
+
+ var contactSpeedFwd = dot(wheelContactSpeed, wheelFwd);
+ var contactSpeedRight = dot(wheelContactSpeed, wheelRight);
+
+ if (wsRef.state !== WHEEL_STATE_NORMAL) bAlreadySkidding = true;
+ wsRef.state = WHEEL_STATE_NORMAL;
+
+ adhesion *= STEP;
+ if (bAlreadySkidding) adhesion *= this.h.fTractionLoss;
+
+ if (contactSpeedRight !== 0.0) {
+ right = -contactSpeedRight / wheelsOnGround;
+ }
+
+ if (bDriving) {
+ fwd = thrust;
+ if (right > 0.0) { if (right > adhesion) right = adhesion; }
+ else { if (right < -adhesion) right = -adhesion; }
+ } else if (contactSpeedFwd !== 0.0) {
+ fwd = -contactSpeedFwd / wheelsOnGround;
+ if (!bBraking) {
+ if (this.m_fGasPedal < 0.01) {
+ if (this.m_fMass < 500.0)
+ brake = 0.2 * WHEEL_FRICTION / this.m_fMass;
+ else
+ brake = WHEEL_FRICTION / this.m_fMass;
+ }
+ }
+ if (brake > adhesion) {
+ if (Math.abs(contactSpeedFwd) > 0.005) wsRef.state = WHEEL_STATE_FIXED;
+ } else {
+ if (fwd > 0.0) { if (fwd > brake) fwd = brake; }
+ else { if (fwd < -brake) fwd = -brake; }
+ }
+ }
+
+ var speedSq = sq(right) + sq(fwd);
+ if (sq(adhesion) < speedSq) {
+ if (wsRef.state !== WHEEL_STATE_FIXED) {
+ if (bDriving && contactSpeedFwd < 0.2) wsRef.state = WHEEL_STATE_SPINNING;
+ else wsRef.state = WHEEL_STATE_SKIDDING;
+ }
+ var l = Math.sqrt(speedSq);
+ var tractionLoss = bAlreadySkidding ? 1.0 : this.h.fTractionLoss;
+ right *= adhesion * tractionLoss / l;
+ fwd *= adhesion * tractionLoss / l;
+ }
+
+ if (fwd !== 0.0 || right !== 0.0) {
+ var totalSpeed = vadd(vscale(wheelFwd, fwd), vscale(wheelRight, right));
+
+ var turnDirection = vcopy(totalSpeed);
+ var separateTurnForce = false;
+ var antidive = this.h.fSuspensionAntidiveMultiplier;
+ if (antidive > 0.0) {
+ if (bBraking) {
+ separateTurnForce = true;
+ turnDirection = vsub(totalSpeed, vscale(wheelFwd, antidive * fwd));
+ } else if (bDriving) {
+ separateTurnForce = true;
+ turnDirection = vsub(totalSpeed, vscale(wheelFwd, 0.5 * antidive * fwd));
+ }
+ }
+
+ var direction = vcopy(totalSpeed);
+ var speed = mag(totalSpeed);
+ var turnSpeed = separateTurnForce ? mag(turnDirection) : speed;
+ direction = norm(direction);
+ if (separateTurnForce) turnDirection = norm(turnDirection);
+ else turnDirection = direction;
+
+ var impulse = speed * this.m_fMass;
+ var turnImpulse = turnSpeed * this.getMass(wheelContactPoint, turnDirection);
+
+ this.applyMoveForce(vscale(direction, impulse));
+ this.applyTurnForce(vscale(turnDirection, turnImpulse), wheelContactPoint);
+ }
+ };
+
+ // CVehicle::ProcessWheelRotation (visual)
+ function processWheelRotation(state, fwd, speed, radius) {
+ var angularVelocity;
+ if (state === WHEEL_STATE_SPINNING) angularVelocity = -1.1;
+ else if (state === WHEEL_STATE_FIXED) angularVelocity = 0.0;
+ else angularVelocity = -dot(fwd, speed) / radius;
+ return angularVelocity * STEP;
+ }
+
+ // =====================================================================
+ // Suspension raycast against the heightfield world
+ // =====================================================================
+ // groundInfo(x,z) -> {h, nx,ny,nz}. Provided by caller (built from groundH).
+ Car.prototype.raycastWheels = function (ground) {
+ for (var i = 0; i < 4; i++) {
+ this.m_aSuspensionSpringRatioPrev[i] = this.m_aSuspensionSpringRatio[i];
+ var mountW = vadd(this.pos, this.toWorldDir(this.m_wheelPosBody[i])); // world mount
+ var springDir = this.toWorldDir(this.m_springDirBody); // world down-ish
+ springDir = norm(springDir);
+ // ray: from mount along springDir, length lineLength. Find ground crossing.
+ // Sample ground under the ray at the mount's (x,z) projected downward.
+ // Because terrain is a heightfield, march the ray and find where it passes below ground.
+ var L = this.m_suspLineLength;
+ var found = false, distHit = L, gh = 0, gnorm = v(0, 1, 0);
+ // coarse+fine march
+ var N = 8, prevAbove = null, prevT = 0;
+ for (var k = 0; k <= N; k++) {
+ var t = L * k / N;
+ var px = mountW.x + springDir.x * t;
+ var py = mountW.y + springDir.y * t;
+ var pz = mountW.z + springDir.z * t;
+ var g = ground(px, pz);
+ var above = py - g.h; // >0 means ray point above ground
+ if (above <= 0 && prevAbove !== null && prevAbove > 0) {
+ // crossing between prevT and t -> refine
+ var t0 = prevT, t1 = t, a0 = prevAbove, a1 = above;
+ for (var r = 0; r < 6; r++) {
+ var tm = 0.5 * (t0 + t1);
+ var mx = mountW.x + springDir.x * tm, mz = mountW.z + springDir.z * tm, my = mountW.y + springDir.y * tm;
+ var gg = ground(mx, mz);
+ var am = my - gg.h;
+ if (am <= 0) { t1 = tm; a1 = am; } else { t0 = tm; a0 = am; }
+ }
+ distHit = 0.5 * (t0 + t1);
+ var hx = mountW.x + springDir.x * distHit, hz = mountW.z + springDir.z * distHit;
+ var gh2 = ground(hx, hz);
+ gnorm = v(gh2.nx, gh2.ny, gh2.nz);
+ found = true;
+ break;
+ }
+ prevAbove = above; prevT = t;
+ }
+
+ if (found) {
+ // raw ratio along the line, then rescale by wheel radius (VC)
+ var wheelRadiusNorm = 1.0 - this.m_suspSpringLength / this.m_suspLineLength;
+ var rawRatio = distHit / this.m_suspLineLength;
+ var ratio = (rawRatio - wheelRadiusNorm) / (1.0 - wheelRadiusNorm);
+ if (ratio < 0) ratio = 0;
+ if (ratio > 1) ratio = 1;
+ this.m_aSuspensionSpringRatio[i] = ratio;
+ // contact point (relative to pos)
+ var cpW = v(mountW.x + springDir.x * distHit, mountW.y + springDir.y * distHit, mountW.z + springDir.z * distHit);
+ this.m_wheelContactPoint[i] = vsub(cpW, this.pos);
+ this.m_wheelContactNormal[i] = norm(gnorm);
+ this.m_wheelOnGround[i] = ratio < 1.0;
+ } else {
+ this.m_aSuspensionSpringRatio[i] = 1.0;
+ this.m_wheelContactPoint[i] = this.toWorldDir(v(this.m_wheelPosBody[i].x, this.m_wheelPosBody[i].y - this.m_suspLineLength, this.m_wheelPosBody[i].z));
+ this.m_wheelOnGround[i] = false;
+ }
+ }
+ };
+
+ // =====================================================================
+ // Main per-step control (CAutomobile::ProcessControl driving core) 1:1
+ // =====================================================================
+ Car.prototype.processControl = function (ground) {
+ var i;
+ var h = this.h, T = h.Transmission;
+
+ // --- CPhysical::ProcessControl: gravity + air resistance (order as VC) ---
+ this.applyGravity();
+ this.applyAirResistance();
+
+ // --- suspension raycast (our world-collision analog) ---
+ this.raycastWheels(ground);
+
+ var fwdWorld = this.fwd;
+ var fwdSpeed = Math.abs(dot(this.moveSpeed, fwdWorld));
+
+ var contactPoints = [null, null, null, null];
+ var contactSpeeds = [null, null, null, null];
+ var springDirections = [null, null, null, null];
+
+ // gather compressed springs
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 1.0) {
+ contactPoints[i] = this.m_wheelContactPoint[i];
+ springDirections[i] = norm(this.toWorldDir(this.m_springDirBody));
+ } else {
+ contactPoints[i] = this.m_wheelContactPoint[i];
+ }
+ }
+
+ // springs push up
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 1.0) {
+ var bias = h.fSuspensionBias;
+ if (i === RL || i === RR) bias = 1.0 - bias;
+ var fd = this.applySpringCollisionAlt(h.fSuspensionForceLevel, springDirections[i],
+ contactPoints[i], this.m_aSuspensionSpringRatio[i], bias, this.m_wheelContactNormal[i]);
+ springDirections[i] = fd; // VC then uses this as spring dir for dampening below
+ }
+ }
+
+ // recompute contact speeds; if normal.z>0.35 use -normal as spring dir (VC: normal.y here)
+ for (i = 0; i < 4; i++) {
+ contactSpeeds[i] = this.getSpeed(contactPoints[i]);
+ if (this.m_aSuspensionSpringRatio[i] < 1.0 && this.m_wheelContactNormal[i].y > 0.35)
+ springDirections[i] = vscale(this.m_wheelContactNormal[i], -1.0);
+ }
+
+ // dampen springs
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 0.99999 && springDirections[i])
+ this.applySpringDampening(h.fSuspensionDampingLevel, springDirections[i], contactPoints[i], contactSpeeds[i]);
+ }
+
+ // recompute contact speeds
+ for (i = 0; i < 4; i++) contactSpeeds[i] = this.getSpeed(contactPoints[i]);
+
+ // --- engine acceleration ---
+ fwdSpeed = dot(this.moveSpeed, fwdWorld);
+ var acceleration = calcDriveAccel(T, this.m_fGasPedal, this.gearRef, fwdSpeed, false);
+
+ var brake = this.m_fBrakePedal * h.fBrakeDeceleration * STEP;
+ var brakeBiasFront = 2.0 * h.fBrakeBias;
+ var brakeBiasRear = 2.0 - h.fBrakeBias;
+ var tractionBiasFront = 2.0 * h.fTractionBias;
+ var tractionBiasRear = 2.0 - tractionBiasFront;
+
+ // count wheels on ground
+ this.m_nWheelsOnGround = 0;
+ this.m_nDriveWheelsOnGround = 0;
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 1.0) this.m_aWheelTimer[i] = 4.0;
+ else this.m_aWheelTimer[i] = Math.max(this.m_aWheelTimer[i] - STEP, 0.0);
+ if (this.m_aWheelTimer[i] > 0.0) {
+ this.m_nWheelsOnGround++;
+ if (T.nDriveType === '4') this.m_nDriveWheelsOnGround++;
+ else if (T.nDriveType === 'F') { if (i === FL || i === FR) this.m_nDriveWheelsOnGround++; }
+ else if (T.nDriveType === 'R') { if (i === RL || i === RR) this.m_nDriveWheelsOnGround++; }
+ }
+ }
+
+ // traction (STATUS_PLAYER path)
+ var traction = 0.004;
+ traction *= h.fTractionMultiplier / 4.0;
+
+ var hasFront = (T.nDriveType !== 'R');
+ var hasRear = (T.nDriveType !== 'F');
+
+ var wheelsOnGround = Math.max(this.m_nWheelsOnGround, 1);
+
+ // ---- FRONT wheels ----
+ if (this.m_aWheelTimer[FL] > 0.0 || this.m_aWheelTimer[FR] > 0.0) {
+ var s = Math.sin(this.m_fSteerAngle), c = Math.cos(this.m_fSteerAngle);
+ for (var fi = 0; fi < 2; fi++) {
+ var wi = fi === 0 ? FL : FR;
+ if (this.m_aWheelTimer[wi] <= 0.0) continue;
+ var fThrust = hasFront ? acceleration : 0.0;
+ var n = this.m_wheelContactNormal[wi];
+ var wFwd = vsub(fwdWorld, vscale(n, dot(fwdWorld, n)));
+ wFwd = norm(wFwd);
+ var wRight = norm(cross(wFwd, n));
+ var tmp = vsub(vscale(wFwd, c), vscale(wRight, s));
+ wRight = vadd(vscale(wFwd, s), vscale(wRight, c));
+ wFwd = tmp;
+ var adhesion = this.adhesiveLimit(this.m_wheelContactNormal[wi]) * traction;
+ var wsRef = { state: this.m_aWheelState[wi] };
+ this.processWheel(wFwd, wRight, contactSpeeds[wi], contactPoints[wi],
+ wheelsOnGround, fThrust, brake * brakeBiasFront, adhesion * tractionBiasFront, wi, wsRef);
+ this.m_aWheelState[wi] = wsRef.state;
+ this.m_aWheelSpeed[wi] = processWheelRotation(wsRef.state, wFwd, contactSpeeds[wi], this.wheelRadius);
+ this.m_aWheelRotation[wi] += this.m_aWheelSpeed[wi];
+ }
+ } else {
+ // front wheels off ground (visual spin decay)
+ for (var fo = 0; fo < 2; fo++) {
+ var wo = fo === 0 ? FL : FR;
+ if (hasFront && acceleration !== 0.0) {
+ if (acceleration > 0.0) { if (this.m_aWheelSpeed[wo] < 2.0) this.m_aWheelSpeed[wo] -= 0.2; }
+ else { if (this.m_aWheelSpeed[wo] > -2.0) this.m_aWheelSpeed[wo] += 0.1; }
+ } else this.m_aWheelSpeed[wo] *= 0.95;
+ this.m_aWheelRotation[wo] += this.m_aWheelSpeed[wo];
+ }
+ }
+
+ // ---- REAR wheels ----
+ if (this.m_aWheelTimer[RL] > 0.0 || this.m_aWheelTimer[RR] > 0.0) {
+ var rearBrake = brake;
+ var rearTraction = traction;
+ if (this.bIsHandbrakeOn) {
+ rearBrake = 20000.0;
+ } else if (this.m_doingBurnout && hasRear) {
+ rearBrake = 0.0; rearTraction = 0.0;
+ // VC: ApplyTurnForce(contactPoints[REAR_LEFT], -0.001*turnMass*steer*GetRight()) (force j, point p)
+ this.applyTurnForce(contactPoints[RL], vscale(this.right, -0.001 * this.m_fTurnMass * this.m_fSteerAngle));
+ } else if (this.m_fTireTemperature > 1.0) {
+ rearTraction *= this.m_fTireTemperature;
+ }
+ for (var ri = 0; ri < 2; ri++) {
+ var rwi = ri === 0 ? RL : RR;
+ if (this.m_aWheelTimer[rwi] <= 0.0) continue;
+ var rThrust = hasRear ? acceleration : 0.0;
+ var rn = this.m_wheelContactNormal[rwi];
+ var rFwd = vsub(fwdWorld, vscale(rn, dot(fwdWorld, rn)));
+ rFwd = norm(rFwd);
+ var rRight = norm(cross(rFwd, rn));
+ var rAdh = this.adhesiveLimit(rn) * rearTraction;
+ var rwsRef = { state: this.m_aWheelState[rwi] };
+ this.processWheel(rFwd, rRight, contactSpeeds[rwi], contactPoints[rwi],
+ wheelsOnGround, rThrust, rearBrake * brakeBiasRear, rAdh * tractionBiasRear, rwi, rwsRef);
+ this.m_aWheelState[rwi] = rwsRef.state;
+ this.m_aWheelSpeed[rwi] = processWheelRotation(rwsRef.state, rFwd, contactSpeeds[rwi], this.wheelRadius);
+ this.m_aWheelRotation[rwi] += this.m_aWheelSpeed[rwi];
+ }
+ } else {
+ for (var rro = 0; rro < 2; rro++) {
+ var rwo = rro === 0 ? RL : RR;
+ if (hasRear && acceleration !== 0.0) {
+ if (acceleration > 0.0) { if (this.m_aWheelSpeed[rwo] < 2.0) this.m_aWheelSpeed[rwo] -= 0.2; }
+ else { if (this.m_aWheelSpeed[rwo] > -2.0) this.m_aWheelSpeed[rwo] += 0.1; }
+ } else this.m_aWheelSpeed[rwo] *= 0.95;
+ this.m_aWheelRotation[rwo] += this.m_aWheelSpeed[rwo];
+ }
+ }
+
+ if (this.m_doingBurnout && !this.bIsHandbrakeOn) { /* keep */ } else this.m_doingBurnout = 0;
+ };
+
+ // adhesive limit for our terrain (ADHESIVE_LOOSE dirt vs ADHESIVE_RUBBER wheel).
+ // Firm dirt/grass ~1.0; steep/loose a bit less. Representative of VC surface.dat.
+ Car.prototype.adhesiveLimit = function (normal) {
+ // steeper ground -> a touch less grip
+ var flat = Math.max(0, normal.y);
+ return 0.92 + 0.08 * flat;
+ };
+
+ // integrate: ApplyMoveSpeed + ApplyTurnSpeed + reorthonormalize
+ Car.prototype.integrate = function () {
+ // position
+ vaddscaled(this.pos, this.moveSpeed, STEP);
+ // orientation: rotate axes by turnSpeed (denormalizes), then reorthonormalize
+ var tv = vscale(this.turnSpeed, STEP);
+ this.right = vadd(this.right, cross(tv, this.right));
+ this.up = vadd(this.up, cross(tv, this.up));
+ this.fwd = vadd(this.fwd, cross(tv, this.fwd));
+ // Gram-Schmidt (Y-up, Z-fwd, X-right)
+ this.up = norm(this.up);
+ this.fwd = norm(vsub(this.fwd, vscale(this.up, dot(this.fwd, this.up))));
+ this.right = norm(cross(this.up, this.fwd));
+ // rebuild fwd exactly orthogonal
+ this.fwd = norm(cross(this.right, this.up));
+ };
+
+ // --- input -> pedals & steering (CAutomobile::ProcessControl player path) 1:1 ---
+ // inp: {throttle:-1..1 (W - S), steer:-1..1 (left neg?), handbrake:bool}
+ Car.prototype.setInput = function (inp) {
+ var speed = dot(this.moveSpeed, this.fwd);
+ this.bIsHandbrakeOn = !!inp.handbrake;
+
+ // steering low-pass then squared curve
+ var targetSteer = inp.steer; // -1..1
+ this.m_fSteerInput += (targetSteer - this.m_fSteerInput) * 0.2 * STEP;
+ if (this.m_fSteerInput > 1) this.m_fSteerInput = 1;
+ if (this.m_fSteerInput < -1) this.m_fSteerInput = -1;
+ var fValue = this.m_fSteerInput < 0 ? -sq(this.m_fSteerInput) : sq(this.m_fSteerInput);
+ this.m_fSteerAngle = DEGTORAD(this.h.fSteeringLock) * fValue;
+
+ // accelerate / brake
+ var acceleration = inp.throttle; // (accel - brake), -1..1
+ if (Math.abs(speed) < 0.01) {
+ if (inp.throttle > 0.58 && inp.brakeHeld) { // both -> burnout
+ this.m_fGasPedal = inp.throttle; this.m_fBrakePedal = 1.0; this.m_doingBurnout = 1;
+ } else {
+ this.m_fGasPedal = acceleration; this.m_fBrakePedal = 0.0;
+ }
+ } else {
+ if (speed * acceleration < 0.0) { this.m_fGasPedal = 0.0; this.m_fBrakePedal = Math.abs(acceleration); }
+ else { this.m_fGasPedal = acceleration; this.m_fBrakePedal = 0.0; }
+ }
+ if (this.bIsHandbrakeOn) { this.m_fBrakePedal = 0.0; }
+ };
+
+ // one fixed 50 Hz step
+ Car.prototype.step = function (inp, ground) {
+ this.setInput(inp);
+ this.processControl(ground);
+ this.integrate();
+ };
+
+ // speed helpers
+ Car.prototype.speedKmh = function () { return mag(this.moveSpeed) * 50 * 3.6; };
+ Car.prototype.fwdSpeedMs = function () { return dot(this.moveSpeed, this.fwd) * 50; };
+
+ // =====================================================================
+ // Default handling presets (VC-range values; model is the 1:1 part)
+ // =====================================================================
+ // Presets: the *model* above is the 1:1 reVC port. These per-car numbers use
+ // VC-plausible ranges tuned to stay planted on Планета Жопа's fbm terrain
+ // (the original HANDLING.CFG isn't shipped in the repo — it's game data).
+ var PRESETS = {
+ // БРОВЕНОСЕЦ 4x4 — reliable all-rounder: fast (≈100 km/h), climbs the butt-hills,
+ // grippy 4WD, tall soft suspension. The friendly default.
+ brovenosec: {
+ name: 'БРОВЕНОСЕЦ 4x4', mass: 1650, dimX: 2.1, dimY: 4.9, dimZ: 1.5,
+ comX: 0, comY: 0.0, comZ: -0.15, submerged: 80,
+ tractionMult: 1.1, tractionLoss: 0.9, tractionBias: 0.5,
+ nGears: 5, maxVel: 270, engineAccel: 34, driveType: '4', engineType: 'P',
+ brakeDecel: 9, brakeBias: 0.5, abs: 0, steerLock: 38,
+ susForce: 1.2, susDamp: 0.18, susUpper: 0.32, susLower: -0.30, susBias: 0.5,
+ susAntidive: 0.3, collDmg: 0.3, flags: 0
+ },
+ // ЖОПЕРРАРИ — rear-drive sport: quick and tail-happy on flats, slides in corners,
+ // low & wide. Bogs a little launching up steep hills (proper RWD character).
+ zhoperrari: {
+ name: 'ЖОПЕРРАРИ', mass: 1500, dimX: 2.0, dimY: 4.9, dimZ: 1.35,
+ comX: 0, comY: 0.0, comZ: -0.25, submerged: 82,
+ tractionMult: 1.30, tractionLoss: 0.82, tractionBias: 0.52,
+ nGears: 5, maxVel: 300, engineAccel: 32, driveType: 'R', engineType: 'P',
+ brakeDecel: 10, brakeBias: 0.52, abs: 0, steerLock: 38,
+ susForce: 1.15, susDamp: 0.16, susUpper: 0.28, susLower: -0.28, susBias: 0.5,
+ susAntidive: 0.35, collDmg: 0.6, flags: 0
+ }
+ };
+
+ var api = {
+ Car: Car, makeHandling: makeHandling, PRESETS: PRESETS,
+ // build a ground(x,z) sampler from a groundH function (adds normals).
+ // opts.patch = tire contact-patch radius: the tire bridges bumps smaller than
+ // itself, so we sample height as a small patch-average and take the normal over
+ // the same width. This is a heightfield adaptation (VC reads a real collision
+ // mesh), NOT a change to the handling model — it just stops a hard suspension
+ // from chattering on sub-tire noise. Larger patch = calmer ride.
+ makeGround: function (groundH, water_y, opts) {
+ opts = opts || {};
+ var e = opts.patch || 1.5; // normal/patch half-width (m)
+ return function (x, z) {
+ // 5-tap patch-averaged height (center + 4 around at radius e)
+ var hC = groundH(x, z);
+ var hX = groundH(x + e, z), hXm = groundH(x - e, z);
+ var hZ = groundH(x, z + e), hZm = groundH(x, z - e);
+ var h = (hC * 2 + hX + hXm + hZ + hZm) / 6;
+ // normal from the patch gradient
+ var nx = (hXm - hX) / (2 * e), nz = (hZm - hZ) / (2 * e), ny = 1.0;
+ var m = Math.sqrt(nx * nx + ny * ny + nz * nz);
+ return { h: h, nx: nx / m, ny: ny / m, nz: nz / m };
+ };
+ },
+ _v: v, _dot: dot, _cross: cross, _mag: mag
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ root.PZCarPhys = api;
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/serve/index.html b/serve/index.html
index 8041504..a335c21 100644
--- a/serve/index.html
+++ b/serve/index.html
@@ -2191,6 +2191,7 @@ const _fwd = new THREE.Vector3(), _rgt = new THREE.Vector3(), _up = new THREE.Ve
const _err = new THREE.Vector3(), _eb = new THREE.Vector3(), _qi = new THREE.Quaternion(), _q1 = new THREE.Quaternion(), _q2 = new THREE.Quaternion(), _dq = new THREE.Quaternion(), _qe = new THREE.Quaternion(), _acc = new THREE.Vector3();
const _camF = new THREE.Vector3(), _camR = new THREE.Vector3(), _camGoal = new THREE.Vector3(), _camLook = new THREE.Vector3(70, 22, -60), _tmp = new THREE.Vector3();
function step(dt) {
+ if (carMode) { carStep(dt); return; }
dt = Math.min(dt, 0.03);
if (!isFinite(S.s)) S.s = 0;
const direct = S.mode === 'direct';
@@ -2373,7 +2374,7 @@ function tick(now) {
arrowSet.position.copy(S.p); arrowSet.setDirection(S.dbgSet);
}
// camera: chase (always behind the drone) or free orbit
- if (camMode === 'chase') {
+ if (carMode) { carCamera(); } else if (camMode === 'chase') {
controls.enabled = false;
_camF.set(0, 0, 1).applyQuaternion(S.q); _camF.y = 0; if (_camF.lengthSq() < 1e-3) _camF.set(0, 0, 1); _camF.normalize();
_camGoal.copy(S.p).addScaledVector(_camF, -18).addScaledVector(WUP, 7);
@@ -2392,6 +2393,7 @@ function tick(now) {
(_dispE.setFromQuaternion(S.q, 'YXZ'), ' крен: ' + (_dispE.z * 57.3).toFixed(0) + '° тангаж: ' + (_dispE.x * 57.3).toFixed(0)) +
'° · онлайн: ' + (1 + Object.keys(peers).length) + (mpStatus ? ' ' + mpStatus : '') +
(freezePos ? ' · ⏸ ПОЗИЦИЯ ЗАФИКС.' : '') + (dbgVec ? ' · ↑вектора' : '');
+ if (carMode && carObj) { hud.textContent = '\uD83D\uDE97 ' + carObj.h.name + ' \u0441\u043A\u043E\u0440: ' + carObj.speedKmh().toFixed(0) + ' \u043A\u043C/\u0447 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0430: ' + carObj.gearRef.gear + ' \u00B7 \u043E\u043D\u043B\u0430\u0439\u043D: ' + (1 + Object.keys(peers).length) + (mpStatus ? ' ' + mpStatus : ''); }
rcTick(); // ЖОПО-СВО: рубежи + гоночный HUD
dayNight.updateSky(now); // цикл день/ночь: солнце/луна/звёзды/небо/свет/туман
skyGroup.position.copy(camera.position); // sky/planets ride with the camera (at infinity, no clip)
@@ -2467,7 +2469,7 @@ function connectMP() {
}
connectMP();
setInterval(() => {
- if (wsConn && wsConn.readyState === 1) wsConn.send(JSON.stringify({
+ if (!carMode && wsConn && wsConn.readyState === 1) wsConn.send(JSON.stringify({
t: 'state', n: myName, s: {
x: +S.p.x.toFixed(2), y: +S.p.y.toFixed(2), z: +S.p.z.toFixed(2),
q: [+S.q.x.toFixed(3), +S.q.y.toFixed(3), +S.q.z.toFixed(3), +S.q.w.toFixed(3)],
@@ -2513,6 +2515,944 @@ if (('ontouchstart' in window) || navigator.maxTouchPoints > 0) {
}
addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); rtScene.setSize(Math.floor(innerWidth * renderer.getPixelRatio()), Math.floor(innerHeight * renderer.getPixelRatio())); });
try { window.PZ = { THREE, scene, camera, S, groundH, WATER_Y, herd, herdMeshes, MAP, MAPS: PZ_MAPS }; } catch(e) {}
+/* ==== PZ_CAR_INTEGRATION (inlined car_phys.js + _carmod.js) ==== */
+/* =====================================================================
+ Планета Жопа — АВТОФИЗИКА
+ Faithful JS port of the GTA:VC (reVC) car handling model.
+ Ported 1:1 from kirillsurkov/racing_game (reVC "miami"):
+ - cTransmission::InitGearRatios / CalculateDriveAcceleration (Transmission.cpp)
+ - cHandlingDataMgr::ConvertDataToGameUnits (HandlingMgr.cpp)
+ - CVehicle::ProcessWheel (tyre traction/skid model) (Vehicle.cpp)
+ - CAutomobile::ProcessControl driving core (susp+drive+brake+steer)
+ - CPhysical rigid body: ApplyMoveForce/ApplyTurnForce/GetMass,
+ ApplySpringCollisionAlt, ApplySpringDampening, ApplyAirResistance,
+ ApplyGravity, ApplyMoveSpeed, ApplyTurnSpeed (Physical.cpp)
+ Units: 1 unit = 1 m, 1 step = 1/50 s (GetTimeStep()==1.0). Runs at fixed 50 Hz.
+ Coordinate frame adapted to the game's world: Y up (VC uses Z up).
+ Works in node (plain {x,y,z}) and in-browser (same code).
+ ===================================================================== */
+(function (root) {
+ 'use strict';
+
+ // ---- constants (from reVC) ----
+ var GRAVITY = 0.008; // Physical.h #define GRAVITY (0.008f)
+ var WHEEL_FRICTION = 0.9; // cHandlingDataMgr::Initialise fWheelFriction
+ var STEP = 1.0; // CTimer::GetTimeStep() nominal (50 Hz)
+ // wheel indices (CARWHEEL_*)
+ var FL = 0, FR = 1, RL = 2, RR = 3;
+ // tWheelState
+ var WHEEL_STATE_NORMAL = 0, WHEEL_STATE_SPINNING = 1, WHEEL_STATE_SKIDDING = 2, WHEEL_STATE_FIXED = 3;
+
+ // ---- tiny vec3 on {x,y,z} ----
+ function v(x, y, z) { return { x: x || 0, y: y || 0, z: z || 0 }; }
+ function vset(a, x, y, z) { a.x = x; a.y = y; a.z = z; return a; }
+ function vcopy(a) { return { x: a.x, y: a.y, z: a.z }; }
+ function vadd(a, b) { return v(a.x + b.x, a.y + b.y, a.z + b.z); }
+ function vsub(a, b) { return v(a.x - b.x, a.y - b.y, a.z - b.z); }
+ function vscale(a, s) { return v(a.x * s, a.y * s, a.z * s); }
+ function vaddi(a, b) { a.x += b.x; a.y += b.y; a.z += b.z; return a; }
+ function vaddscaled(a, b, s) { a.x += b.x * s; a.y += b.y * s; a.z += b.z * s; return a; }
+ function dot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
+ function cross(a, b) {
+ return v(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
+ }
+ function magsq(a) { return a.x * a.x + a.y * a.y + a.z * a.z; }
+ function mag(a) { return Math.sqrt(magsq(a)); }
+ function norm(a) { var m = mag(a); if (m > 1e-9) { return v(a.x / m, a.y / m, a.z / m); } return v(0, 0, 0); }
+ function sq(x) { return x * x; }
+ function DEGTORAD(d) { return d * Math.PI / 180; }
+
+ // =====================================================================
+ // Handling: raw cfg fields -> game units (ConvertDataToGameUnits 1:1)
+ // =====================================================================
+ // raw fields mirror HANDLING.CFG columns (post the *0.4 that LoadHandlingData
+ // applies to engineAccel at field 14).
+ function makeHandling(raw) {
+ var h = {
+ name: raw.name,
+ fMass: raw.mass,
+ Dimension: v(raw.dimX, raw.dimY, raw.dimZ),
+ CentreOfMass: v(raw.comX, raw.comY, raw.comZ),
+ nPercentSubmerged: raw.submerged,
+ fTractionMultiplier: raw.tractionMult,
+ fTractionLoss: raw.tractionLoss,
+ fTractionBias: raw.tractionBias,
+ fBrakeDeceleration: raw.brakeDecel,
+ fBrakeBias: raw.brakeBias,
+ bABS: raw.abs,
+ fSteeringLock: raw.steerLock,
+ fSuspensionForceLevel: raw.susForce,
+ fSuspensionDampingLevel: raw.susDamp,
+ fSuspensionUpperLimit: raw.susUpper,
+ fSuspensionLowerLimit: raw.susLower,
+ fSuspensionBias: raw.susBias,
+ fSuspensionAntidiveMultiplier: raw.susAntidive || 0,
+ fCollisionDamageMultiplier: raw.collDmg || 1,
+ Flags: raw.flags || 0,
+ Transmission: {
+ nNumberOfGears: raw.nGears,
+ nDriveType: raw.driveType, // 'F' | 'R' | '4'
+ nEngineType: raw.engineType || 'P',
+ Flags: raw.flags || 0,
+ fEngineAcceleration: raw.engineAccel * 0.4, // LoadHandlingData field 14: *0.4
+ fMaxVelocity: raw.maxVel,
+ Gears: [ {}, {}, {}, {}, {}, {} ]
+ }
+ };
+ convertToGameUnits(h);
+ initGearRatios(h.Transmission);
+ // moment of inertia already set by convert
+ return h;
+ }
+
+ function convertToGameUnits(h) {
+ var T = h.Transmission;
+ T.fEngineAcceleration *= 1.0 / (50.0 * 50.0);
+ T.fMaxVelocity *= 1000.0 / (60.0 * 60.0 * 50.0);
+ h.fBrakeDeceleration *= 1.0 / (50.0 * 50.0);
+ h.fTurnMass = (sq(h.Dimension.x) + sq(h.Dimension.y)) * h.fMass / 12.0;
+ if (h.fTurnMass < 10.0) h.fTurnMass *= 5.0;
+ h.fInvMass = 1.0 / h.fMass;
+ h.fBuoyancy = 100.0 / h.nPercentSubmerged * GRAVITY * h.fMass;
+
+ // drag-limited real max velocity (ConvertDataToGameUnits 1:1)
+ var a = 0.0, b = 100.0, velocity = T.fMaxVelocity;
+ while (a < b && velocity > 0.0) {
+ velocity -= 0.01;
+ a = T.fEngineAcceleration / 6.0;
+ var a_drag = 0.5 * sq(velocity) * h.Dimension.x * h.Dimension.z / h.fMass;
+ b = -velocity * (1.0 / (a_drag + 1.0) - 1.0);
+ }
+ T.fMaxCruiseVelocity = velocity;
+ T.fMaxVelocity = velocity * 1.2;
+ T.fMaxReverseVelocity = -0.2;
+
+ if (T.nDriveType === '4') T.fEngineAcceleration /= 4.0;
+ else T.fEngineAcceleration /= 2.0;
+ }
+
+ function initGearRatios(T) {
+ var G = T.Gears;
+ var i;
+ for (i = 0; i < 6; i++) G[i] = { fMaxVelocity: 0, fShiftUpVelocity: 0, fShiftDownVelocity: 0 };
+ for (i = 1; i <= T.nNumberOfGears; i++) {
+ var g0 = G[i - 1], g1 = G[i];
+ g1.fMaxVelocity = i / T.nNumberOfGears * T.fMaxVelocity;
+ var velocityDiff = g1.fMaxVelocity - g0.fMaxVelocity;
+ if (i >= T.nNumberOfGears) {
+ g1.fShiftUpVelocity = T.fMaxVelocity;
+ } else {
+ G[i + 1].fShiftDownVelocity = velocityDiff * 0.42 + g0.fMaxVelocity;
+ g1.fShiftUpVelocity = velocityDiff * 0.6667 + g0.fMaxVelocity;
+ }
+ }
+ G[0].fMaxVelocity = T.fMaxReverseVelocity;
+ G[0].fShiftUpVelocity = -0.01;
+ G[0].fShiftDownVelocity = T.fMaxReverseVelocity;
+ G[1].fShiftDownVelocity = -0.01;
+ }
+
+ var HANDLING_2G_BOOST = 2, HANDLING_1G_BOOST = 1;
+
+ // cTransmission::CalculateDriveAcceleration (recursive gear selection) 1:1
+ function calcDriveAccel(T, gasPedal, gearRef, velocity, cheat) {
+ var fVelocity = velocity;
+ if (fVelocity < T.fMaxReverseVelocity) { return 0.0; }
+ if (fVelocity > T.fMaxVelocity) { return 0.0; }
+
+ var gear = gearRef.gear;
+ var pGearRatio = T.Gears[gear];
+ if (fVelocity > pGearRatio.fShiftUpVelocity) {
+ if (gear !== 0 || gasPedal > 0.0) {
+ gearRef.gear = gear + 1;
+ return calcDriveAccel(T, gasPedal, gearRef, fVelocity, false);
+ }
+ } else if (fVelocity < pGearRatio.fShiftDownVelocity && gear !== 0) {
+ if (gear !== 1 || gasPedal < 0.0) {
+ gearRef.gear = gear - 1;
+ return calcDriveAccel(T, gasPedal, gearRef, fVelocity, false);
+ }
+ }
+
+ var speedMul, accelMul;
+ var Flags = T.Flags;
+ if (gear < 1) {
+ accelMul = (Flags & HANDLING_2G_BOOST) ? 2.0 : 1.0;
+ speedMul = -1.0;
+ } else if (T.nNumberOfGears === 1) {
+ accelMul = 1.0; speedMul = 1.0;
+ } else {
+ var f = 1.0 - (gear - 1) / (T.nNumberOfGears - 1);
+ speedMul = 3.0 * sq(f) + 1.0;
+ if (Flags & HANDLING_2G_BOOST) {
+ if (gear === 1) accelMul = (Flags & HANDLING_1G_BOOST) ? 2.0 : 1.6;
+ else if (gear === 2) accelMul = 1.3;
+ else accelMul = 1.0;
+ } else if ((Flags & HANDLING_1G_BOOST) && gear === 1) {
+ accelMul = 2.0;
+ } else accelMul = 1.0;
+ }
+
+ var fCheat = cheat ? 1.2 : 1.0;
+ var targetVelocity = T.Gears[gear].fMaxVelocity * speedMul * fCheat;
+ var accel = (targetVelocity - fVelocity) * (T.fEngineAcceleration * accelMul) / Math.abs(targetVelocity);
+ var fAcceleration;
+ if (Math.abs(fVelocity) < Math.abs(T.Gears[gear].fMaxVelocity * fCheat))
+ fAcceleration = gasPedal * accel * STEP;
+ else
+ fAcceleration = 0.0;
+ return fAcceleration;
+ }
+
+ // =====================================================================
+ // Car rigid body
+ // =====================================================================
+ function Car(handling, opts) {
+ opts = opts || {};
+ this.h = handling;
+ this.m_fMass = handling.fMass;
+ this.m_fTurnMass = handling.fTurnMass;
+ this.m_vecCentreOfMass = vcopy(handling.CentreOfMass);
+ this.m_fAirResistance = handling.Dimension.x * handling.Dimension.z / handling.fMass; // CAutomobile ctor
+
+ // pose: position + orthonormal basis (right,up,fwd)
+ this.pos = v(opts.x || 0, opts.y || 0, opts.z || 0);
+ this.right = v(1, 0, 0);
+ this.up = v(0, 1, 0);
+ this.fwd = v(0, 0, 1);
+ if (opts.heading != null) this.setHeading(opts.heading);
+
+ // velocities
+ this.moveSpeed = v(0, 0, 0); // m_vecMoveSpeed (units/step)
+ this.turnSpeed = v(0, 0, 0); // m_vecTurnSpeed (rad/step, world)
+
+ // controls
+ this.m_fGasPedal = 0;
+ this.m_fBrakePedal = 0;
+ this.m_fSteerAngle = 0;
+ this.m_fSteerInput = 0;
+ this.bIsHandbrakeOn = false;
+ this.m_doingBurnout = 0;
+
+ // transmission state
+ this.gearRef = { gear: 1 };
+ this.m_fTireTemperature = 1.0;
+
+ // per-wheel state
+ var i;
+ this.m_aSuspensionSpringRatio = [1, 1, 1, 1];
+ this.m_aSuspensionSpringRatioPrev = [1, 1, 1, 1];
+ this.m_aWheelTimer = [0, 0, 0, 0];
+ this.m_aWheelSpeed = [0, 0, 0, 0]; // wheel angular speed (visual)
+ this.m_aWheelRotation = [0, 0, 0, 0]; // accumulated wheel angle (visual)
+ this.m_aWheelState = [0, 0, 0, 0];
+ this.m_wheelContactPoint = [v(), v(), v(), v()]; // world, relative to pos
+ this.m_wheelContactNormal = [v(0, 1, 0), v(0, 1, 0), v(0, 1, 0), v(0, 1, 0)];
+ this.m_wheelOnGround = [false, false, false, false];
+
+ this.m_nWheelsOnGround = 0;
+ this.m_nDriveWheelsOnGround = 0;
+
+ // --- suspension geometry (per wheel, body space) ---
+ // Wheel mount X (side): +right = right side. Y(height): mount above hub. Z(fwd): fore/aft.
+ var d = handling.Dimension;
+ var halfW = d.x * 0.5; // track half-width
+ var halfL = d.y * 0.42; // wheelbase half-length
+ this.wheelRadius = opts.wheelRadius || Math.max(0.32, d.z * 0.22);
+ var Ls = handling.fSuspensionUpperLimit - handling.fSuspensionLowerLimit; // spring travel
+ if (Ls < 0.05) Ls = 0.30;
+ this.m_suspSpringLength = Ls;
+ // total ray length = spring travel + wheel radius (VC: lineLength = springLength + radius)
+ this.m_suspLineLength = Ls + this.wheelRadius;
+ // mount height above body origin so hub sits ~ at origin
+ var mountY = handling.fSuspensionUpperLimit;
+ // wheel body positions: [FL, FR, RL, RR]; +Z is forward, +X is right
+ this.m_wheelPosBody = [
+ v(-halfW, mountY, halfL), // FRONT_LEFT
+ v( halfW, mountY, halfL), // FRONT_RIGHT
+ v(-halfW, mountY, -halfL), // REAR_LEFT
+ v( halfW, mountY, -halfL) // REAR_RIGHT
+ ];
+ // spring direction in body space = straight down (-up)
+ this.m_springDirBody = v(0, -1, 0);
+ // height above road (approx, VC m_fHeightAboveRoad)
+ this.m_fHeightAboveRoad = Ls * (1.0 - 1.0 / (4.0 * handling.fSuspensionForceLevel)) - handling.fSuspensionLowerLimit;
+ }
+
+ Car.prototype.setHeading = function (rad) {
+ // rotate basis about world-Y so fwd points to heading (0 => +Z)
+ var c = Math.cos(rad), s = Math.sin(rad);
+ this.fwd = v(s, 0, c);
+ this.up = v(0, 1, 0);
+ this.right = norm(cross(this.up, this.fwd));
+ };
+
+ Car.prototype.heading = function () { return Math.atan2(this.fwd.x, this.fwd.z); };
+
+ // world position of a body-space point
+ Car.prototype.toWorldDir = function (b) {
+ return v(
+ this.right.x * b.x + this.up.x * b.y + this.fwd.x * b.z,
+ this.right.y * b.x + this.up.y * b.y + this.fwd.y * b.z,
+ this.right.z * b.x + this.up.z * b.y + this.fwd.z * b.z
+ );
+ };
+
+ // CPhysical::GetSpeed(r): velocity of a point r (relative to pos)
+ Car.prototype.getSpeed = function (r) {
+ return vadd(this.moveSpeed, cross(this.turnSpeed, r));
+ };
+
+ // CPhysical::ApplyMoveForce
+ Car.prototype.applyMoveForce = function (f) {
+ vaddi(this.moveSpeed, vscale(f, 1.0 / this.m_fMass));
+ };
+ // CPhysical::ApplyTurnForce (impulse j at point p, relative to pos)
+ Car.prototype.applyTurnForce = function (j, p) {
+ var com = this.toWorldDir(this.m_vecCentreOfMass);
+ var turnimpulse = cross(vsub(p, com), j);
+ vaddi(this.turnSpeed, vscale(turnimpulse, 1.0 / this.m_fTurnMass));
+ };
+ // CPhysical::GetMass(pos,dir)
+ Car.prototype.getMass = function (pos, dir) {
+ return 1.0 / (magsq(cross(pos, dir)) / this.m_fTurnMass + 1.0 / this.m_fMass);
+ };
+
+ Car.prototype.applyGravity = function () {
+ this.moveSpeed.y -= GRAVITY * STEP;
+ };
+
+ // CPhysical::ApplyAirResistance
+ Car.prototype.applyAirResistance = function () {
+ if (this.m_fAirResistance > 0.1) {
+ var f = Math.pow(this.m_fAirResistance, STEP);
+ this.moveSpeed = vscale(this.moveSpeed, f);
+ this.turnSpeed = vscale(this.turnSpeed, f);
+ } else {
+ var f2 = Math.pow(1.0 / Math.abs(1.0 + this.m_fAirResistance * 0.5 * magsq(this.moveSpeed)), STEP);
+ this.moveSpeed = vscale(this.moveSpeed, f2);
+ this.turnSpeed = vscale(this.turnSpeed, 0.99);
+ }
+ };
+
+ // CPhysical::ApplySpringCollisionAlt
+ Car.prototype.applySpringCollisionAlt = function (springConst, springDir, point, springRatio, bias, forceDir) {
+ var compression = 1.0 - springRatio;
+ if (compression > 0.0) {
+ var fd = vcopy(forceDir);
+ if (dot(springDir, fd) > 0.0) fd = vscale(fd, -1.0);
+ var step = Math.min(STEP, 3.0);
+ var impulse = GRAVITY * this.m_fMass * step * springConst * compression * bias * 2.0;
+ this.applyMoveForce(vscale(fd, impulse));
+ this.applyTurnForce(vscale(fd, impulse), point);
+ return fd; // return possibly-flipped force dir (VC mutates forceDir; used as springDir below)
+ }
+ return forceDir;
+ };
+
+ // CPhysical::ApplySpringDampening
+ Car.prototype.applySpringDampening = function (damping, springDir, point, speed) {
+ var speedA = dot(speed, springDir);
+ var gs = this.getSpeed(point);
+ var speedB = dot(gs, springDir);
+ if (speedB === 0.0) return;
+ var step = Math.min(STEP, 3.0);
+ var impulse = -damping * (speedA + speedB) / 2.0 * this.m_fMass * step * 0.53;
+ var a = this.m_fTurnMass / ((magsq(point) + 1.0) * 2.0 * this.m_fMass);
+ a = Math.min(a, 1.0);
+ var b = Math.abs(impulse / (speedB * this.m_fMass));
+ if (a < b) impulse *= a / b;
+ this.applyMoveForce(vscale(springDir, impulse));
+ this.applyTurnForce(vscale(springDir, impulse), point);
+ };
+
+ // CVehicle::ProcessWheel (tyre traction/skid) 1:1
+ Car.prototype.processWheel = function (wheelFwd, wheelRight, wheelContactSpeed, wheelContactPoint,
+ wheelsOnGround, thrust, brake, adhesion, wheelId, wsRef) {
+ var bAlreadySkidding = false;
+ var fwd = 0.0, right = 0.0;
+
+ var bBraking = brake !== 0.0;
+ if (bBraking) thrust = 0.0;
+ var bDriving = thrust !== 0.0;
+
+ var contactSpeedFwd = dot(wheelContactSpeed, wheelFwd);
+ var contactSpeedRight = dot(wheelContactSpeed, wheelRight);
+
+ if (wsRef.state !== WHEEL_STATE_NORMAL) bAlreadySkidding = true;
+ wsRef.state = WHEEL_STATE_NORMAL;
+
+ adhesion *= STEP;
+ if (bAlreadySkidding) adhesion *= this.h.fTractionLoss;
+
+ if (contactSpeedRight !== 0.0) {
+ right = -contactSpeedRight / wheelsOnGround;
+ }
+
+ if (bDriving) {
+ fwd = thrust;
+ if (right > 0.0) { if (right > adhesion) right = adhesion; }
+ else { if (right < -adhesion) right = -adhesion; }
+ } else if (contactSpeedFwd !== 0.0) {
+ fwd = -contactSpeedFwd / wheelsOnGround;
+ if (!bBraking) {
+ if (this.m_fGasPedal < 0.01) {
+ if (this.m_fMass < 500.0)
+ brake = 0.2 * WHEEL_FRICTION / this.m_fMass;
+ else
+ brake = WHEEL_FRICTION / this.m_fMass;
+ }
+ }
+ if (brake > adhesion) {
+ if (Math.abs(contactSpeedFwd) > 0.005) wsRef.state = WHEEL_STATE_FIXED;
+ } else {
+ if (fwd > 0.0) { if (fwd > brake) fwd = brake; }
+ else { if (fwd < -brake) fwd = -brake; }
+ }
+ }
+
+ var speedSq = sq(right) + sq(fwd);
+ if (sq(adhesion) < speedSq) {
+ if (wsRef.state !== WHEEL_STATE_FIXED) {
+ if (bDriving && contactSpeedFwd < 0.2) wsRef.state = WHEEL_STATE_SPINNING;
+ else wsRef.state = WHEEL_STATE_SKIDDING;
+ }
+ var l = Math.sqrt(speedSq);
+ var tractionLoss = bAlreadySkidding ? 1.0 : this.h.fTractionLoss;
+ right *= adhesion * tractionLoss / l;
+ fwd *= adhesion * tractionLoss / l;
+ }
+
+ if (fwd !== 0.0 || right !== 0.0) {
+ var totalSpeed = vadd(vscale(wheelFwd, fwd), vscale(wheelRight, right));
+
+ var turnDirection = vcopy(totalSpeed);
+ var separateTurnForce = false;
+ var antidive = this.h.fSuspensionAntidiveMultiplier;
+ if (antidive > 0.0) {
+ if (bBraking) {
+ separateTurnForce = true;
+ turnDirection = vsub(totalSpeed, vscale(wheelFwd, antidive * fwd));
+ } else if (bDriving) {
+ separateTurnForce = true;
+ turnDirection = vsub(totalSpeed, vscale(wheelFwd, 0.5 * antidive * fwd));
+ }
+ }
+
+ var direction = vcopy(totalSpeed);
+ var speed = mag(totalSpeed);
+ var turnSpeed = separateTurnForce ? mag(turnDirection) : speed;
+ direction = norm(direction);
+ if (separateTurnForce) turnDirection = norm(turnDirection);
+ else turnDirection = direction;
+
+ var impulse = speed * this.m_fMass;
+ var turnImpulse = turnSpeed * this.getMass(wheelContactPoint, turnDirection);
+
+ this.applyMoveForce(vscale(direction, impulse));
+ this.applyTurnForce(vscale(turnDirection, turnImpulse), wheelContactPoint);
+ }
+ };
+
+ // CVehicle::ProcessWheelRotation (visual)
+ function processWheelRotation(state, fwd, speed, radius) {
+ var angularVelocity;
+ if (state === WHEEL_STATE_SPINNING) angularVelocity = -1.1;
+ else if (state === WHEEL_STATE_FIXED) angularVelocity = 0.0;
+ else angularVelocity = -dot(fwd, speed) / radius;
+ return angularVelocity * STEP;
+ }
+
+ // =====================================================================
+ // Suspension raycast against the heightfield world
+ // =====================================================================
+ // groundInfo(x,z) -> {h, nx,ny,nz}. Provided by caller (built from groundH).
+ Car.prototype.raycastWheels = function (ground) {
+ for (var i = 0; i < 4; i++) {
+ this.m_aSuspensionSpringRatioPrev[i] = this.m_aSuspensionSpringRatio[i];
+ var mountW = vadd(this.pos, this.toWorldDir(this.m_wheelPosBody[i])); // world mount
+ var springDir = this.toWorldDir(this.m_springDirBody); // world down-ish
+ springDir = norm(springDir);
+ // ray: from mount along springDir, length lineLength. Find ground crossing.
+ // Sample ground under the ray at the mount's (x,z) projected downward.
+ // Because terrain is a heightfield, march the ray and find where it passes below ground.
+ var L = this.m_suspLineLength;
+ var found = false, distHit = L, gh = 0, gnorm = v(0, 1, 0);
+ // coarse+fine march
+ var N = 8, prevAbove = null, prevT = 0;
+ for (var k = 0; k <= N; k++) {
+ var t = L * k / N;
+ var px = mountW.x + springDir.x * t;
+ var py = mountW.y + springDir.y * t;
+ var pz = mountW.z + springDir.z * t;
+ var g = ground(px, pz);
+ var above = py - g.h; // >0 means ray point above ground
+ if (above <= 0 && prevAbove !== null && prevAbove > 0) {
+ // crossing between prevT and t -> refine
+ var t0 = prevT, t1 = t, a0 = prevAbove, a1 = above;
+ for (var r = 0; r < 6; r++) {
+ var tm = 0.5 * (t0 + t1);
+ var mx = mountW.x + springDir.x * tm, mz = mountW.z + springDir.z * tm, my = mountW.y + springDir.y * tm;
+ var gg = ground(mx, mz);
+ var am = my - gg.h;
+ if (am <= 0) { t1 = tm; a1 = am; } else { t0 = tm; a0 = am; }
+ }
+ distHit = 0.5 * (t0 + t1);
+ var hx = mountW.x + springDir.x * distHit, hz = mountW.z + springDir.z * distHit;
+ var gh2 = ground(hx, hz);
+ gnorm = v(gh2.nx, gh2.ny, gh2.nz);
+ found = true;
+ break;
+ }
+ prevAbove = above; prevT = t;
+ }
+
+ if (found) {
+ // raw ratio along the line, then rescale by wheel radius (VC)
+ var wheelRadiusNorm = 1.0 - this.m_suspSpringLength / this.m_suspLineLength;
+ var rawRatio = distHit / this.m_suspLineLength;
+ var ratio = (rawRatio - wheelRadiusNorm) / (1.0 - wheelRadiusNorm);
+ if (ratio < 0) ratio = 0;
+ if (ratio > 1) ratio = 1;
+ this.m_aSuspensionSpringRatio[i] = ratio;
+ // contact point (relative to pos)
+ var cpW = v(mountW.x + springDir.x * distHit, mountW.y + springDir.y * distHit, mountW.z + springDir.z * distHit);
+ this.m_wheelContactPoint[i] = vsub(cpW, this.pos);
+ this.m_wheelContactNormal[i] = norm(gnorm);
+ this.m_wheelOnGround[i] = ratio < 1.0;
+ } else {
+ this.m_aSuspensionSpringRatio[i] = 1.0;
+ this.m_wheelContactPoint[i] = this.toWorldDir(v(this.m_wheelPosBody[i].x, this.m_wheelPosBody[i].y - this.m_suspLineLength, this.m_wheelPosBody[i].z));
+ this.m_wheelOnGround[i] = false;
+ }
+ }
+ };
+
+ // =====================================================================
+ // Main per-step control (CAutomobile::ProcessControl driving core) 1:1
+ // =====================================================================
+ Car.prototype.processControl = function (ground) {
+ var i;
+ var h = this.h, T = h.Transmission;
+
+ // --- CPhysical::ProcessControl: gravity + air resistance (order as VC) ---
+ this.applyGravity();
+ this.applyAirResistance();
+
+ // --- suspension raycast (our world-collision analog) ---
+ this.raycastWheels(ground);
+
+ var fwdWorld = this.fwd;
+ var fwdSpeed = Math.abs(dot(this.moveSpeed, fwdWorld));
+
+ var contactPoints = [null, null, null, null];
+ var contactSpeeds = [null, null, null, null];
+ var springDirections = [null, null, null, null];
+
+ // gather compressed springs
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 1.0) {
+ contactPoints[i] = this.m_wheelContactPoint[i];
+ springDirections[i] = norm(this.toWorldDir(this.m_springDirBody));
+ } else {
+ contactPoints[i] = this.m_wheelContactPoint[i];
+ }
+ }
+
+ // springs push up
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 1.0) {
+ var bias = h.fSuspensionBias;
+ if (i === RL || i === RR) bias = 1.0 - bias;
+ var fd = this.applySpringCollisionAlt(h.fSuspensionForceLevel, springDirections[i],
+ contactPoints[i], this.m_aSuspensionSpringRatio[i], bias, this.m_wheelContactNormal[i]);
+ springDirections[i] = fd; // VC then uses this as spring dir for dampening below
+ }
+ }
+
+ // recompute contact speeds; if normal.z>0.35 use -normal as spring dir (VC: normal.y here)
+ for (i = 0; i < 4; i++) {
+ contactSpeeds[i] = this.getSpeed(contactPoints[i]);
+ if (this.m_aSuspensionSpringRatio[i] < 1.0 && this.m_wheelContactNormal[i].y > 0.35)
+ springDirections[i] = vscale(this.m_wheelContactNormal[i], -1.0);
+ }
+
+ // dampen springs
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 0.99999 && springDirections[i])
+ this.applySpringDampening(h.fSuspensionDampingLevel, springDirections[i], contactPoints[i], contactSpeeds[i]);
+ }
+
+ // recompute contact speeds
+ for (i = 0; i < 4; i++) contactSpeeds[i] = this.getSpeed(contactPoints[i]);
+
+ // --- engine acceleration ---
+ fwdSpeed = dot(this.moveSpeed, fwdWorld);
+ var acceleration = calcDriveAccel(T, this.m_fGasPedal, this.gearRef, fwdSpeed, false);
+
+ var brake = this.m_fBrakePedal * h.fBrakeDeceleration * STEP;
+ var brakeBiasFront = 2.0 * h.fBrakeBias;
+ var brakeBiasRear = 2.0 - h.fBrakeBias;
+ var tractionBiasFront = 2.0 * h.fTractionBias;
+ var tractionBiasRear = 2.0 - tractionBiasFront;
+
+ // count wheels on ground
+ this.m_nWheelsOnGround = 0;
+ this.m_nDriveWheelsOnGround = 0;
+ for (i = 0; i < 4; i++) {
+ if (this.m_aSuspensionSpringRatio[i] < 1.0) this.m_aWheelTimer[i] = 4.0;
+ else this.m_aWheelTimer[i] = Math.max(this.m_aWheelTimer[i] - STEP, 0.0);
+ if (this.m_aWheelTimer[i] > 0.0) {
+ this.m_nWheelsOnGround++;
+ if (T.nDriveType === '4') this.m_nDriveWheelsOnGround++;
+ else if (T.nDriveType === 'F') { if (i === FL || i === FR) this.m_nDriveWheelsOnGround++; }
+ else if (T.nDriveType === 'R') { if (i === RL || i === RR) this.m_nDriveWheelsOnGround++; }
+ }
+ }
+
+ // traction (STATUS_PLAYER path)
+ var traction = 0.004;
+ traction *= h.fTractionMultiplier / 4.0;
+
+ var hasFront = (T.nDriveType !== 'R');
+ var hasRear = (T.nDriveType !== 'F');
+
+ var wheelsOnGround = Math.max(this.m_nWheelsOnGround, 1);
+
+ // ---- FRONT wheels ----
+ if (this.m_aWheelTimer[FL] > 0.0 || this.m_aWheelTimer[FR] > 0.0) {
+ var s = Math.sin(this.m_fSteerAngle), c = Math.cos(this.m_fSteerAngle);
+ for (var fi = 0; fi < 2; fi++) {
+ var wi = fi === 0 ? FL : FR;
+ if (this.m_aWheelTimer[wi] <= 0.0) continue;
+ var fThrust = hasFront ? acceleration : 0.0;
+ var n = this.m_wheelContactNormal[wi];
+ var wFwd = vsub(fwdWorld, vscale(n, dot(fwdWorld, n)));
+ wFwd = norm(wFwd);
+ var wRight = norm(cross(wFwd, n));
+ var tmp = vsub(vscale(wFwd, c), vscale(wRight, s));
+ wRight = vadd(vscale(wFwd, s), vscale(wRight, c));
+ wFwd = tmp;
+ var adhesion = this.adhesiveLimit(this.m_wheelContactNormal[wi]) * traction;
+ var wsRef = { state: this.m_aWheelState[wi] };
+ this.processWheel(wFwd, wRight, contactSpeeds[wi], contactPoints[wi],
+ wheelsOnGround, fThrust, brake * brakeBiasFront, adhesion * tractionBiasFront, wi, wsRef);
+ this.m_aWheelState[wi] = wsRef.state;
+ this.m_aWheelSpeed[wi] = processWheelRotation(wsRef.state, wFwd, contactSpeeds[wi], this.wheelRadius);
+ this.m_aWheelRotation[wi] += this.m_aWheelSpeed[wi];
+ }
+ } else {
+ // front wheels off ground (visual spin decay)
+ for (var fo = 0; fo < 2; fo++) {
+ var wo = fo === 0 ? FL : FR;
+ if (hasFront && acceleration !== 0.0) {
+ if (acceleration > 0.0) { if (this.m_aWheelSpeed[wo] < 2.0) this.m_aWheelSpeed[wo] -= 0.2; }
+ else { if (this.m_aWheelSpeed[wo] > -2.0) this.m_aWheelSpeed[wo] += 0.1; }
+ } else this.m_aWheelSpeed[wo] *= 0.95;
+ this.m_aWheelRotation[wo] += this.m_aWheelSpeed[wo];
+ }
+ }
+
+ // ---- REAR wheels ----
+ if (this.m_aWheelTimer[RL] > 0.0 || this.m_aWheelTimer[RR] > 0.0) {
+ var rearBrake = brake;
+ var rearTraction = traction;
+ if (this.bIsHandbrakeOn) {
+ rearBrake = 20000.0;
+ } else if (this.m_doingBurnout && hasRear) {
+ rearBrake = 0.0; rearTraction = 0.0;
+ // VC: ApplyTurnForce(contactPoints[REAR_LEFT], -0.001*turnMass*steer*GetRight()) (force j, point p)
+ this.applyTurnForce(contactPoints[RL], vscale(this.right, -0.001 * this.m_fTurnMass * this.m_fSteerAngle));
+ } else if (this.m_fTireTemperature > 1.0) {
+ rearTraction *= this.m_fTireTemperature;
+ }
+ for (var ri = 0; ri < 2; ri++) {
+ var rwi = ri === 0 ? RL : RR;
+ if (this.m_aWheelTimer[rwi] <= 0.0) continue;
+ var rThrust = hasRear ? acceleration : 0.0;
+ var rn = this.m_wheelContactNormal[rwi];
+ var rFwd = vsub(fwdWorld, vscale(rn, dot(fwdWorld, rn)));
+ rFwd = norm(rFwd);
+ var rRight = norm(cross(rFwd, rn));
+ var rAdh = this.adhesiveLimit(rn) * rearTraction;
+ var rwsRef = { state: this.m_aWheelState[rwi] };
+ this.processWheel(rFwd, rRight, contactSpeeds[rwi], contactPoints[rwi],
+ wheelsOnGround, rThrust, rearBrake * brakeBiasRear, rAdh * tractionBiasRear, rwi, rwsRef);
+ this.m_aWheelState[rwi] = rwsRef.state;
+ this.m_aWheelSpeed[rwi] = processWheelRotation(rwsRef.state, rFwd, contactSpeeds[rwi], this.wheelRadius);
+ this.m_aWheelRotation[rwi] += this.m_aWheelSpeed[rwi];
+ }
+ } else {
+ for (var rro = 0; rro < 2; rro++) {
+ var rwo = rro === 0 ? RL : RR;
+ if (hasRear && acceleration !== 0.0) {
+ if (acceleration > 0.0) { if (this.m_aWheelSpeed[rwo] < 2.0) this.m_aWheelSpeed[rwo] -= 0.2; }
+ else { if (this.m_aWheelSpeed[rwo] > -2.0) this.m_aWheelSpeed[rwo] += 0.1; }
+ } else this.m_aWheelSpeed[rwo] *= 0.95;
+ this.m_aWheelRotation[rwo] += this.m_aWheelSpeed[rwo];
+ }
+ }
+
+ if (this.m_doingBurnout && !this.bIsHandbrakeOn) { /* keep */ } else this.m_doingBurnout = 0;
+ };
+
+ // adhesive limit for our terrain (ADHESIVE_LOOSE dirt vs ADHESIVE_RUBBER wheel).
+ // Firm dirt/grass ~1.0; steep/loose a bit less. Representative of VC surface.dat.
+ Car.prototype.adhesiveLimit = function (normal) {
+ // steeper ground -> a touch less grip
+ var flat = Math.max(0, normal.y);
+ return 0.92 + 0.08 * flat;
+ };
+
+ // integrate: ApplyMoveSpeed + ApplyTurnSpeed + reorthonormalize
+ Car.prototype.integrate = function () {
+ // position
+ vaddscaled(this.pos, this.moveSpeed, STEP);
+ // orientation: rotate axes by turnSpeed (denormalizes), then reorthonormalize
+ var tv = vscale(this.turnSpeed, STEP);
+ this.right = vadd(this.right, cross(tv, this.right));
+ this.up = vadd(this.up, cross(tv, this.up));
+ this.fwd = vadd(this.fwd, cross(tv, this.fwd));
+ // Gram-Schmidt (Y-up, Z-fwd, X-right)
+ this.up = norm(this.up);
+ this.fwd = norm(vsub(this.fwd, vscale(this.up, dot(this.fwd, this.up))));
+ this.right = norm(cross(this.up, this.fwd));
+ // rebuild fwd exactly orthogonal
+ this.fwd = norm(cross(this.right, this.up));
+ };
+
+ // --- input -> pedals & steering (CAutomobile::ProcessControl player path) 1:1 ---
+ // inp: {throttle:-1..1 (W - S), steer:-1..1 (left neg?), handbrake:bool}
+ Car.prototype.setInput = function (inp) {
+ var speed = dot(this.moveSpeed, this.fwd);
+ this.bIsHandbrakeOn = !!inp.handbrake;
+
+ // steering low-pass then squared curve
+ var targetSteer = inp.steer; // -1..1
+ this.m_fSteerInput += (targetSteer - this.m_fSteerInput) * 0.2 * STEP;
+ if (this.m_fSteerInput > 1) this.m_fSteerInput = 1;
+ if (this.m_fSteerInput < -1) this.m_fSteerInput = -1;
+ var fValue = this.m_fSteerInput < 0 ? -sq(this.m_fSteerInput) : sq(this.m_fSteerInput);
+ this.m_fSteerAngle = DEGTORAD(this.h.fSteeringLock) * fValue;
+
+ // accelerate / brake
+ var acceleration = inp.throttle; // (accel - brake), -1..1
+ if (Math.abs(speed) < 0.01) {
+ if (inp.throttle > 0.58 && inp.brakeHeld) { // both -> burnout
+ this.m_fGasPedal = inp.throttle; this.m_fBrakePedal = 1.0; this.m_doingBurnout = 1;
+ } else {
+ this.m_fGasPedal = acceleration; this.m_fBrakePedal = 0.0;
+ }
+ } else {
+ if (speed * acceleration < 0.0) { this.m_fGasPedal = 0.0; this.m_fBrakePedal = Math.abs(acceleration); }
+ else { this.m_fGasPedal = acceleration; this.m_fBrakePedal = 0.0; }
+ }
+ if (this.bIsHandbrakeOn) { this.m_fBrakePedal = 0.0; }
+ };
+
+ // one fixed 50 Hz step
+ Car.prototype.step = function (inp, ground) {
+ this.setInput(inp);
+ this.processControl(ground);
+ this.integrate();
+ };
+
+ // speed helpers
+ Car.prototype.speedKmh = function () { return mag(this.moveSpeed) * 50 * 3.6; };
+ Car.prototype.fwdSpeedMs = function () { return dot(this.moveSpeed, this.fwd) * 50; };
+
+ // =====================================================================
+ // Default handling presets (VC-range values; model is the 1:1 part)
+ // =====================================================================
+ // Presets: the *model* above is the 1:1 reVC port. These per-car numbers use
+ // VC-plausible ranges tuned to stay planted on Планета Жопа's fbm terrain
+ // (the original HANDLING.CFG isn't shipped in the repo — it's game data).
+ var PRESETS = {
+ // БРОВЕНОСЕЦ 4x4 — reliable all-rounder: fast (≈100 km/h), climbs the butt-hills,
+ // grippy 4WD, tall soft suspension. The friendly default.
+ brovenosec: {
+ name: 'БРОВЕНОСЕЦ 4x4', mass: 1650, dimX: 2.1, dimY: 4.9, dimZ: 1.5,
+ comX: 0, comY: 0.0, comZ: -0.15, submerged: 80,
+ tractionMult: 1.1, tractionLoss: 0.9, tractionBias: 0.5,
+ nGears: 5, maxVel: 270, engineAccel: 34, driveType: '4', engineType: 'P',
+ brakeDecel: 9, brakeBias: 0.5, abs: 0, steerLock: 38,
+ susForce: 1.2, susDamp: 0.18, susUpper: 0.32, susLower: -0.30, susBias: 0.5,
+ susAntidive: 0.3, collDmg: 0.3, flags: 0
+ },
+ // ЖОПЕРРАРИ — rear-drive sport: quick and tail-happy on flats, slides in corners,
+ // low & wide. Bogs a little launching up steep hills (proper RWD character).
+ zhoperrari: {
+ name: 'ЖОПЕРРАРИ', mass: 1500, dimX: 2.0, dimY: 4.9, dimZ: 1.35,
+ comX: 0, comY: 0.0, comZ: -0.25, submerged: 82,
+ tractionMult: 1.30, tractionLoss: 0.82, tractionBias: 0.52,
+ nGears: 5, maxVel: 300, engineAccel: 32, driveType: 'R', engineType: 'P',
+ brakeDecel: 10, brakeBias: 0.52, abs: 0, steerLock: 38,
+ susForce: 1.15, susDamp: 0.16, susUpper: 0.28, susLower: -0.28, susBias: 0.5,
+ susAntidive: 0.35, collDmg: 0.6, flags: 0
+ }
+ };
+
+ var api = {
+ Car: Car, makeHandling: makeHandling, PRESETS: PRESETS,
+ // build a ground(x,z) sampler from a groundH function (adds normals).
+ // opts.patch = tire contact-patch radius: the tire bridges bumps smaller than
+ // itself, so we sample height as a small patch-average and take the normal over
+ // the same width. This is a heightfield adaptation (VC reads a real collision
+ // mesh), NOT a change to the handling model — it just stops a hard suspension
+ // from chattering on sub-tire noise. Larger patch = calmer ride.
+ makeGround: function (groundH, water_y, opts) {
+ opts = opts || {};
+ var e = opts.patch || 1.5; // normal/patch half-width (m)
+ return function (x, z) {
+ // 5-tap patch-averaged height (center + 4 around at radius e)
+ var hC = groundH(x, z);
+ var hX = groundH(x + e, z), hXm = groundH(x - e, z);
+ var hZ = groundH(x, z + e), hZm = groundH(x, z - e);
+ var h = (hC * 2 + hX + hXm + hZ + hZm) / 6;
+ // normal from the patch gradient
+ var nx = (hXm - hX) / (2 * e), nz = (hZm - hZ) / (2 * e), ny = 1.0;
+ var m = Math.sqrt(nx * nx + ny * ny + nz * nz);
+ return { h: h, nx: nx / m, ny: ny / m, nz: nz / m };
+ };
+ },
+ _v: v, _dot: dot, _cross: cross, _mag: mag
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ root.PZCarPhys = api;
+})(typeof window !== 'undefined' ? window : globalThis);
+
+/* ===== АВТО-РЕЖИМ: выбор транспорта + езда на ported reVC-физике (client) ===== */
+var carMode = false, carObj = null, carMesh = null, carGround = null, carAcc = 0, carPreset = 'brovenosec';
+var carHeadlights = [];
+var carMesh_wheels = [];
+var CAR_SPAWN = { x: 70, z: -40 };
+
+function buildCarMesh() {
+ var h = carObj.h;
+ var g = new THREE.Group();
+ var L = h.Dimension.y, Wd = h.Dimension.x, Ht = h.Dimension.z;
+ var body = new THREE.Mesh(new THREE.BoxGeometry(Wd, Ht * 0.6, L),
+ new THREE.MeshStandardMaterial({ color: 0xc81e3a, roughness: 0.45, metalness: 0.3 }));
+ body.position.y = Ht * 0.15; g.add(body);
+ var cab = new THREE.Mesh(new THREE.BoxGeometry(Wd * 0.86, Ht * 0.5, L * 0.5),
+ new THREE.MeshStandardMaterial({ color: 0x20242c, roughness: 0.3, metalness: 0.4 }));
+ cab.position.set(0, Ht * 0.55, -L * 0.05); g.add(cab);
+ var nose = new THREE.Mesh(new THREE.BoxGeometry(Wd * 0.5, Ht * 0.2, 0.3),
+ new THREE.MeshStandardMaterial({ color: 0xffd23a, emissive: 0x442200 }));
+ nose.position.set(0, Ht * 0.1, L * 0.5); g.add(nose); // жёлтый нос = вперёд (+Z)
+ // фары — area lights (LTC, как у дрона): светят вперёд (+Z) и чуть вниз на дорогу
+ var hlX = Wd * 0.34, hlY = Ht * 0.16, hlZ = L * 0.5 + 0.04;
+ var lensMat = new THREE.MeshStandardMaterial({ color: 0xfff6d8, emissive: 0xfff0b0, emissiveIntensity: 2.6, roughness: 0.25, metalness: 0.0 });
+ carHeadlights = [];
+ for (var hi = 0; hi < 2; hi++) {
+ var sx = hi === 0 ? -1 : 1;
+ var lens = new THREE.Mesh(new THREE.BoxGeometry(Wd * 0.26, Ht * 0.18, 0.10), lensMat);
+ lens.position.set(sx * hlX, hlY, hlZ); g.add(lens);
+ var hl = new THREE.RectAreaLight(0xfff2cc, 11.0, Wd * 0.55, Ht * 0.42);
+ hl.position.set(sx * hlX, hlY, hlZ + 0.06);
+ hl.rotation.set(Math.PI + 0.16, 0, 0); // локальный -Z -> +Z (вперёд), лёгкий наклон вниз
+ g.add(hl); carHeadlights.push(hl);
+ }
+ var wr = carObj.wheelRadius;
+ var wheelGeo = new THREE.CylinderGeometry(wr, wr, 0.3, 16);
+ var wheelMat = new THREE.MeshStandardMaterial({ color: 0x111214, roughness: 0.85 });
+ carMesh_wheels = [];
+ for (var i = 0; i < 4; i++) {
+ var w = new THREE.Mesh(wheelGeo, wheelMat);
+ var pb = carObj.m_wheelPosBody[i];
+ w.position.set(pb.x, pb.y, pb.z);
+ w.rotation.set(0, 0, Math.PI / 2); // ось колеса вдоль X
+ g.add(w); carMesh_wheels.push(w);
+ }
+ return g;
+}
+
+function spawnCar() {
+ var CP = window.PZCarPhys;
+ var hd = CP.makeHandling(CP.PRESETS[carPreset]);
+ carGround = CP.makeGround(groundH, WATER_Y, { patch: 1.5 });
+ var gy = groundH(CAR_SPAWN.x, CAR_SPAWN.z);
+ carObj = new CP.Car(hd, { x: CAR_SPAWN.x, y: gy + 2.0, z: CAR_SPAWN.z, heading: 0 });
+ carMesh = buildCarMesh();
+ scene.add(carMesh);
+ try { window.PZCAR = carObj; } catch(e){}
+}
+
+function readCarInput() {
+ var th = 0, st = 0;
+ if (keys['KeyW'] || keys['ArrowUp']) th += 1;
+ if (keys['KeyS'] || keys['ArrowDown']) th -= 1;
+ if (keys['KeyA'] || keys['ArrowLeft']) st += 1; // руль влево (исправлено: влево/вправо было перепутано)
+ if (keys['KeyD'] || keys['ArrowRight']) st -= 1; // руль вправо
+ return { throttle: th, steer: st, handbrake: !!keys['Space'], brakeHeld: (th < 0) };
+}
+
+var _cbR = new THREE.Vector3(), _cbU = new THREE.Vector3(), _cbF = new THREE.Vector3(), _cbM = new THREE.Matrix4();
+function carRespawn() {
+ var CP = window.PZCarPhys, gy = groundH(CAR_SPAWN.x, CAR_SPAWN.z);
+ carObj.pos = CP._v(CAR_SPAWN.x, gy + 2.0, CAR_SPAWN.z);
+ carObj.moveSpeed = CP._v(0, 0, 0); carObj.turnSpeed = CP._v(0, 0, 0);
+ carObj.setHeading(0);
+}
+function carStep(dt) {
+ if (!carObj) return;
+ if (keys['KeyR']) carRespawn();
+ var inp = readCarInput(); carAcc += dt; var n = 0;
+ while (carAcc >= 0.02 && n < 6) { carObj.step(inp, carGround); carAcc -= 0.02; n++; } // фиксированные 50 Гц
+ carMesh.position.set(carObj.pos.x, carObj.pos.y, carObj.pos.z);
+ _cbR.set(carObj.right.x, carObj.right.y, carObj.right.z);
+ _cbU.set(carObj.up.x, carObj.up.y, carObj.up.z);
+ _cbF.set(carObj.fwd.x, carObj.fwd.y, carObj.fwd.z);
+ _cbM.makeBasis(_cbR, _cbU, _cbF); carMesh.quaternion.setFromRotationMatrix(_cbM);
+ for (var i = 0; i < 4; i++) {
+ var w = carMesh_wheels[i]; if (!w) continue;
+ var pb = carObj.m_wheelPosBody[i]; w.position.set(pb.x, pb.y, pb.z);
+ var steer = (i < 2) ? carObj.m_fSteerAngle : 0; // FL,FR поворачиваются
+ w.rotation.set(0, -steer, Math.PI / 2);
+ }
+}
+
+var _carGoal = new THREE.Vector3(), _carLook = new THREE.Vector3();
+function carCamera() {
+ controls.enabled = false;
+ _cbF.set(carObj.fwd.x, 0, carObj.fwd.z); if (_cbF.lengthSq() < 1e-4) _cbF.set(0, 0, 1); _cbF.normalize();
+ _carGoal.set(carObj.pos.x, carObj.pos.y, carObj.pos.z).addScaledVector(_cbF, -12).addScaledVector(WUP, 5.5);
+ camera.position.lerp(_carGoal, 0.12);
+ _carLook.set(carObj.pos.x, carObj.pos.y + 1.3, carObj.pos.z);
+ _camLook.lerp(_carLook, 0.3); camera.lookAt(_camLook);
+}
+
+/* --- стартовый выбор транспорта --- */
+(function vehicleSelect() {
+ var ov = document.createElement('div');
+ ov.id = 'vsOverlay';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:10050;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(4,6,12,.88);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);font-family:system-ui,Segoe UI,Roboto,sans-serif;color:#eef3ff';
+ ov.innerHTML =
+ '<div style="font:800 30px system-ui;margin-bottom:6px;text-align:center;letter-spacing:1px">ПЛАНЕТА ЖОПА</div>' +
+ '<div style="opacity:.85;margin-bottom:24px">На чём поедешь?</div>' +
+ '<div style="display:flex;gap:18px;flex-wrap:wrap;justify-content:center">' +
+ '<button id="vsDrone" style="cursor:pointer;border:0;border-radius:16px;padding:24px 28px;min-width:190px;line-height:1.35;background:linear-gradient(180deg,#4aa3ff,#2166c8);color:#fff;font:800 20px system-ui;box-shadow:0 12px 40px rgba(0,0,0,.5)">🚁<br>ДРОН<br><span style="font:500 12px system-ui;opacity:.85">полёт · акро · WASD</span></button>' +
+ '<button id="vsCar" style="cursor:pointer;border:0;border-radius:16px;padding:24px 28px;min-width:190px;line-height:1.35;background:linear-gradient(180deg,#ff6a4a,#c8291e);color:#fff;font:800 20px system-ui;box-shadow:0 12px 40px rgba(0,0,0,.5)">🚗<br>МАШИНА<br><span style="font:500 12px system-ui;opacity:.85">физика reVC · газ/руль</span></button>' +
+ '</div>' +
+ '<div style="margin-top:22px;opacity:.6;font-size:12px">сменить транспорт — перезагрузка страницы</div>';
+ document.body.appendChild(ov);
+ ['pointerdown', 'touchstart', 'mousedown', 'keydown'].forEach(function (ev) { ov.addEventListener(ev, function (e) { e.stopPropagation(); }); });
+ document.getElementById('vsDrone').onclick = function () { carMode = false; ov.remove(); };
+ document.getElementById('vsCar').onclick = function () {
+ carMode = true;
+ try { spawnCar(); } catch (e) { console.error('spawnCar', e); }
+ try { if (typeof drone !== 'undefined' && drone) drone.visible = false; } catch (e) {}
+ try { sd.style.display = 'none'; } catch (e) {}
+ try { dp.style.display = 'none'; } catch (e) {}
+ var hp = document.getElementById('help');
+ if (hp) hp.innerHTML = '<b>🚗 МАШИНА (физика reVC):</b> <kbd>W</kbd> газ · <kbd>S</kbd> тормоз/задний · <kbd>A</kbd>/<kbd>D</kbd> руль · <kbd>Space</kbd> ручник · <kbd>R</kbd> сброс';
+ ov.remove();
+ };
+})();
+
+/* ==== /PZ_CAR_INTEGRATION ==== */
+
requestAnimationFrame(tick);
})();