/* ===================================================================== Планета Жопа — АВТОФИЗИКА 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 (≈250 km/h top, ~140 avg on terrain), 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: 380, engineAccel: 100, 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);