Real car meshes + physics-derived suspension + inPA/void fixes
CI / quality (push) Failing after 56s
CI / quality (push) Failing after 56s
Car meshes: - CarManager: clone ReplicatedStorage/CarModels/<carId> as a visual Shell, weld to Body, hide placeholder blocks. Falls back to procedural CarBody when no mesh exists, so cars can be converted one at a time. - Shell parts are Massless/CanCollide=false/CanQuery=false: the constraint chassis and driving feel are untouched. Verified 0 violating parts live. - 5 catalogue cars mapped from the 67-model ServerStorage/CarLibrary import. Suspension (ref docs/CAR_PHYSICS_REFERENCE.txt, devforum 3915628): - BUG: old k=9000 gave 1.10 studs static sag against 0.90 studs total travel, i.e. the suspension sat permanently bottomed out and the spring did no work. - Stiffness now solved from a sag target (70%% of half travel); damping now derived as a damping ratio c = zeta*2*sqrt(k*m) instead of an unrelated constant. Old pair worked out to zeta=0.31 (floaty); now 0.55-0.63. - Measured after fix: ride height 1.97 studs, bounce 0.000, body roll 0.002. Also: Circuit.inPA square test (PAFloor is 520x520, test was a r=260 circle, corners falsely reported outside and spuriously armed runs), and void-fall recovery now moves the character to the spawn pad BEFORE building the car.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -46,9 +46,23 @@ end
|
||||
function AntiCheat.init()
|
||||
local function watch(player: Player)
|
||||
player.CharacterAdded:Connect(function(char)
|
||||
-- RESPAWN FIX: drop the previous character's baseline IMMEDIATELY,
|
||||
-- before the WaitForChild below yields. A new character spawns at
|
||||
-- the SpawnLocation (the parking area, ~8,500 studs from the old
|
||||
-- body at the origin). If the Heartbeat sampler runs during that
|
||||
-- yield it measures that jump against the STALE baseline, calls it
|
||||
-- a teleport hack, and snaps the fresh character back to the dead
|
||||
-- character's CFrame -- dragging the car with it. Clearing the
|
||||
-- entry means "no baseline yet", so nothing can be flagged until
|
||||
-- the new one is recorded.
|
||||
tracked[player] = nil
|
||||
AntiCheat.grant(player)
|
||||
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
|
||||
if hrp then
|
||||
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
|
||||
-- The spawn itself is a legitimate long-distance move; keep the
|
||||
-- grace alive past the first sample after the baseline lands.
|
||||
AntiCheat.grant(player)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
+180
-2
@@ -49,6 +49,10 @@ local SEAT_LIFT = 3
|
||||
-- The rig must be fully settled first or the client re-asserts a stale layout.
|
||||
local SETTLE_TIME = 1.0
|
||||
|
||||
-- How far to sink an imported mesh shell into the chassis so it doesn't look
|
||||
-- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false).
|
||||
local SHELL_DROP = 0.6
|
||||
|
||||
-- Collision groups ------------------------------------------------------------
|
||||
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
|
||||
-- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has
|
||||
@@ -273,8 +277,48 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
||||
end
|
||||
end
|
||||
|
||||
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
||||
CarBody.build(model, body, car)
|
||||
-- Visual shell. If a real imported mesh exists under
|
||||
-- ReplicatedStorage/CarModels/<carId>, use it; otherwise fall back to the
|
||||
-- procedural CarBody kit. Meshes are visual-ONLY: every part is Massless,
|
||||
-- CanCollide=false and welded to Body, so the constraint chassis (springs,
|
||||
-- steering servos, motor wheels) and therefore the driving feel are
|
||||
-- completely unchanged no matter which shell is used.
|
||||
local shellTemplate = ReplicatedStorage:FindFirstChild("CarModels")
|
||||
shellTemplate = shellTemplate and shellTemplate:FindFirstChild(car.Id)
|
||||
if shellTemplate then
|
||||
local shell = shellTemplate:Clone()
|
||||
shell.Name = "Shell"
|
||||
-- Seat the shell on the chassis: drop it so the mesh's underside sits at
|
||||
-- roughly the body plane rather than floating at the body's centreline.
|
||||
local ext = shell:GetExtentsSize()
|
||||
shell:PivotTo(body.CFrame * CFrame.new(0, ext.Y / 2 - Chassis.BODY_SIZE.Y / 2 - SHELL_DROP, 0))
|
||||
local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true)
|
||||
for _, d in ipairs(shell:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
d.Anchored = false
|
||||
d.CanCollide = false
|
||||
d.CanTouch = false
|
||||
d.CanQuery = false
|
||||
d.Massless = true
|
||||
end
|
||||
end
|
||||
if shellRoot then
|
||||
local w = Instance.new("WeldConstraint")
|
||||
w.Part0 = body
|
||||
w.Part1 = shellRoot
|
||||
w.Parent = body
|
||||
end
|
||||
shell.Parent = model
|
||||
-- Hide the placeholder blocks the mesh replaces.
|
||||
body.Transparency = 1
|
||||
local cabin = model:FindFirstChild("Cabin")
|
||||
if cabin then
|
||||
cabin:Destroy()
|
||||
end
|
||||
else
|
||||
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
||||
CarBody.build(model, body, car)
|
||||
end
|
||||
|
||||
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
|
||||
ensureCollisionGroups()
|
||||
@@ -428,9 +472,143 @@ function CarManager.init()
|
||||
carsFolder.Name = "ActiveCars"
|
||||
carsFolder.Parent = Workspace
|
||||
|
||||
-- FLIP RECOVERY: a car that ends up on its roof has no way to right itself,
|
||||
-- so the player is stranded upside down indefinitely -- endurance testing
|
||||
-- left one inverted (up = -0.6) grinding a barrier for 140 seconds. When a
|
||||
-- car is inverted AND effectively stationary for a few consecutive checks
|
||||
-- (so we never flip someone mid-drift), lift it clear and set it upright,
|
||||
-- preserving heading. Ownership is pulled back to the server for the
|
||||
-- teleport: PivotTo on client-owned parts gets reverted by the owning
|
||||
-- client on the next physics frame.
|
||||
local flipTicks: { [Player]: number } = {}
|
||||
task.spawn(function()
|
||||
while true do
|
||||
task.wait(1)
|
||||
for _, player in ipairs(Players:GetPlayers()) do
|
||||
local ok, err = pcall(function()
|
||||
local model = activeCars[player]
|
||||
if not model or not model.Parent then
|
||||
flipTicks[player] = nil
|
||||
return
|
||||
end
|
||||
local body = model:FindFirstChild("Body")
|
||||
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
|
||||
if not root then
|
||||
return
|
||||
end
|
||||
local upright = root.CFrame.UpVector:Dot(Vector3.yAxis)
|
||||
local slow = root.AssemblyLinearVelocity.Magnitude < 8
|
||||
if upright >= 0.35 or not slow then
|
||||
flipTicks[player] = 0
|
||||
return
|
||||
end
|
||||
flipTicks[player] = (flipTicks[player] or 0) + 1
|
||||
if flipTicks[player] < 3 then
|
||||
return
|
||||
end
|
||||
flipTicks[player] = 0
|
||||
|
||||
local parts: { BasePart } = {}
|
||||
for _, d in ipairs(model:GetDescendants()) do
|
||||
if d:IsA("BasePart") and not d.Anchored then
|
||||
table.insert(parts, d)
|
||||
end
|
||||
end
|
||||
for _, d in ipairs(parts) do
|
||||
pcall(function()
|
||||
d:SetNetworkOwner(nil)
|
||||
end)
|
||||
end
|
||||
|
||||
local look = root.CFrame.LookVector
|
||||
local flat = Vector3.new(look.X, 0, look.Z)
|
||||
if flat.Magnitude < 0.1 then
|
||||
flat = Vector3.new(0, 0, -1)
|
||||
end
|
||||
local target = root.Position + Vector3.new(0, 6, 0)
|
||||
model:PivotTo(CFrame.lookAt(target, target + flat.Unit))
|
||||
for _, d in ipairs(parts) do
|
||||
d.AssemblyLinearVelocity = Vector3.zero
|
||||
d.AssemblyAngularVelocity = Vector3.zero
|
||||
end
|
||||
|
||||
AntiCheat.grant(player, 3)
|
||||
task.wait(0.3)
|
||||
for _, d in ipairs(parts) do
|
||||
pcall(function()
|
||||
d:SetNetworkOwner(player)
|
||||
end)
|
||||
end
|
||||
warn(("[CarManager] righted flipped car for %s"):format(player.Name))
|
||||
end)
|
||||
if not ok then
|
||||
warn("[CarManager] flip-recovery iteration failed: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
CarManager.despawn(player)
|
||||
end)
|
||||
|
||||
-- VOID RECOVERY: nothing else rescues a car that leaves the world. If it
|
||||
-- falls off the map it drops past Workspace.FallenPartsDestroyHeight, the
|
||||
-- model is deleted out from under the driver, and the player is left
|
||||
-- standing (or seatless) with no car and no way to get another -- observed
|
||||
-- in endurance testing as 100+ seconds of a dead session. Watch for a car
|
||||
-- that has fallen below the recovery floor OR whose model was destroyed
|
||||
-- while the player is still in the game, and respawn it on the pad.
|
||||
local RECOVER_Y = Workspace.FallenPartsDestroyHeight + 100
|
||||
task.spawn(function()
|
||||
while true do
|
||||
task.wait(1)
|
||||
for _, player in ipairs(Players:GetPlayers()) do
|
||||
local ok, err = pcall(function()
|
||||
if not player.Parent or not player.Character then
|
||||
return
|
||||
end
|
||||
local model = activeCars[player]
|
||||
if not model then
|
||||
return
|
||||
end
|
||||
local gone = model.Parent == nil
|
||||
local fell = false
|
||||
if not gone then
|
||||
local body = model:FindFirstChild("Body")
|
||||
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
|
||||
fell = root ~= nil and root.Position.Y < RECOVER_Y
|
||||
end
|
||||
if gone or fell then
|
||||
warn(
|
||||
("[CarManager] recovering car for %s (destroyed=%s fell=%s)")
|
||||
:format(player.Name, tostring(gone), tostring(fell))
|
||||
)
|
||||
-- CRITICAL: spawn() teleports the car to wherever the
|
||||
-- CHARACTER is. During a void fall the seated character is
|
||||
-- falling too, so respawning here would drop a fresh car
|
||||
-- into the void and loop forever. Put the character back on
|
||||
-- solid ground FIRST, then build the car around it.
|
||||
local character = player.Character
|
||||
local root = character and character:FindFirstChild("HumanoidRootPart")
|
||||
if root and root:IsA("BasePart") then
|
||||
local safe = spawnCFrame()
|
||||
AntiCheat.grant(player, 5)
|
||||
root.CFrame = safe + Vector3.new(0, 5, 0)
|
||||
root.AssemblyLinearVelocity = Vector3.zero
|
||||
root.AssemblyAngularVelocity = Vector3.zero
|
||||
task.wait(0.2)
|
||||
end
|
||||
local data = DataManager.get(player)
|
||||
CarManager.spawn(player, data and data.EquippedCarId)
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
warn("[CarManager] void-recovery iteration failed: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- The player's live car model, or nil if they have none right now.
|
||||
|
||||
@@ -323,6 +323,64 @@ local function buildPA(map: Folder)
|
||||
})
|
||||
ramp.Parent = pa
|
||||
|
||||
-- PERIMETER BARRIERS: the PA floor is a 520x520 slab floating ~46 studs up
|
||||
-- with nothing at its edges. Endurance testing drove straight off the side
|
||||
-- and fell past FallenPartsDestroyHeight (-500), destroying the car and
|
||||
-- stranding the player seatless until the death/respawn cycle caught it.
|
||||
-- Wall every edge, leaving a gap on the side the ramp exits through so the
|
||||
-- route to the expressway stays open.
|
||||
do
|
||||
local rampDir = Vector3.new(road.X - c.X, 0, road.Z - c.Z)
|
||||
-- Which edge does the ramp leave from? Whichever axis dominates.
|
||||
local rampOnX = math.abs(rampDir.X) > math.abs(rampDir.Z)
|
||||
local rampSign = rampOnX and math.sign(rampDir.X) or math.sign(rampDir.Z)
|
||||
local GAP = 70 -- half-width of the opening (ramp is 46 wide)
|
||||
local H, T = 14, 4
|
||||
|
||||
local function wall(name, size, pos)
|
||||
local w = part({
|
||||
Name = name,
|
||||
Size = size,
|
||||
CFrame = CFrame.new(pos),
|
||||
Color = Color3.fromRGB(96, 96, 104),
|
||||
Material = Enum.Material.Concrete,
|
||||
})
|
||||
w.Parent = pa
|
||||
end
|
||||
|
||||
-- Four edges. On the ramp's edge, build two stubs flanking the opening.
|
||||
local edges = {
|
||||
{ onX = true, sign = 1 },
|
||||
{ onX = true, sign = -1 },
|
||||
{ onX = false, sign = 1 },
|
||||
{ onX = false, sign = -1 },
|
||||
}
|
||||
for _, e in ipairs(edges) do
|
||||
local isRampEdge = (e.onX == rampOnX) and (e.sign == rampSign)
|
||||
-- Offset along the edge normal; span the full slab on the other axis.
|
||||
local normal = e.onX and Vector3.new(e.sign * R, 0, 0) or Vector3.new(0, 0, e.sign * R)
|
||||
local base = c + normal + Vector3.new(0, H / 2, 0)
|
||||
local fullSize = e.onX and Vector3.new(T, H, R * 2) or Vector3.new(R * 2, H, T)
|
||||
if not isRampEdge then
|
||||
wall("PAWall", fullSize, base)
|
||||
else
|
||||
-- two stubs, each running from the corner in to the gap edge
|
||||
local stubLen = R - GAP
|
||||
if stubLen > 4 then
|
||||
local half = stubLen / 2
|
||||
local off = GAP + half
|
||||
if e.onX then
|
||||
wall("PAWall", Vector3.new(T, H, stubLen), base + Vector3.new(0, 0, off))
|
||||
wall("PAWall", Vector3.new(T, H, stubLen), base + Vector3.new(0, 0, -off))
|
||||
else
|
||||
wall("PAWall", Vector3.new(stubLen, H, T), base + Vector3.new(off, 0, 0))
|
||||
wall("PAWall", Vector3.new(stubLen, H, T), base + Vector3.new(-off, 0, 0))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local pad = part({
|
||||
Name = "SpawnPad",
|
||||
Size = Vector3.new(120, 2.4, 120),
|
||||
|
||||
@@ -57,29 +57,77 @@ CarManager.init()
|
||||
ModeManager.init()
|
||||
AntiCheat.init()
|
||||
|
||||
-- 3. Build the world --------------------------------------------------------
|
||||
MapBuilder.build()
|
||||
Ambience.init()
|
||||
-- 3. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||
-- SPAWN FIX: characters must NOT auto-load. MapBuilder.build() yields for
|
||||
-- several seconds and, until it returns, the world contains no SpawnLocation
|
||||
-- at all -- so an auto-loaded character lands at world origin (0, 5, 0),
|
||||
-- ~2,700 studs off the nearest road and ~8,500 from the parking area. Because
|
||||
-- CarManager reassembles the car around the character's root, the car is
|
||||
-- stranded at the origin too, sitting on an isolated pad; drive more than a
|
||||
-- few hundred studs and you fall out of the world forever. Load characters
|
||||
-- only once the map (and its PASpawn SpawnLocation) actually exists.
|
||||
Players.CharacterAutoLoads = false
|
||||
local mapReady = false
|
||||
|
||||
local handled: { [Player]: boolean } = {}
|
||||
|
||||
local function onPlayer(player: Player)
|
||||
if handled[player] then
|
||||
return
|
||||
end
|
||||
handled[player] = true
|
||||
|
||||
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||
Players.PlayerAdded:Connect(function(player)
|
||||
ModeManager.onPlayerAdded(player)
|
||||
DataManager.onReady(player, function()
|
||||
pushCar(player)
|
||||
ModeManager.pushTo(player)
|
||||
end)
|
||||
player.CharacterAdded:Connect(function()
|
||||
player.CharacterAdded:Connect(function(character)
|
||||
DataManager.onReady(player, function(data)
|
||||
task.wait(1)
|
||||
CarManager.spawn(player, data.EquippedCarId)
|
||||
end)
|
||||
-- CharacterAutoLoads = false also disables respawn-on-death, so drive
|
||||
-- it manually or a dead player is stuck as a corpse forever.
|
||||
local humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
|
||||
if humanoid then
|
||||
humanoid.Died:Once(function()
|
||||
task.wait(Players.RespawnTime)
|
||||
if player.Parent then
|
||||
player:LoadCharacter()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
task.spawn(function()
|
||||
while not mapReady do
|
||||
task.wait(0.1)
|
||||
end
|
||||
if player.Parent then
|
||||
player:LoadCharacter()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Connected BEFORE the build so a player joining mid-build is never missed.
|
||||
Players.PlayerAdded:Connect(onPlayer)
|
||||
-- ...and catch anyone already present: in a Studio playtest the local player
|
||||
-- routinely exists the moment this script runs, so PlayerAdded never fires.
|
||||
for _, player in ipairs(Players:GetPlayers()) do
|
||||
task.spawn(onPlayer, player)
|
||||
end
|
||||
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
handled[player] = nil
|
||||
ModeManager.onPlayerRemoving(player)
|
||||
end)
|
||||
|
||||
-- 4. Build the world --------------------------------------------------------
|
||||
MapBuilder.build()
|
||||
Ambience.init()
|
||||
mapReady = true
|
||||
|
||||
-- 5. Remote handlers (validated server-side) --------------------------------
|
||||
rSpawnCar.OnServerEvent:Connect(function(player)
|
||||
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
||||
|
||||
+36
-2
@@ -52,8 +52,42 @@ export type Tuning = {
|
||||
WheelFriction: number,
|
||||
}
|
||||
|
||||
-- Suspension physics ----------------------------------------------------------
|
||||
-- Derived rather than eyeballed. Reference: "In Depth Scripted Car Physics"
|
||||
-- (devforum 3915628) -> docs/CAR_PHYSICS_REFERENCE.txt. That tutorial builds
|
||||
-- raycast suspension, but the underlying spring math is identical for the
|
||||
-- SpringConstraints we use, so the two tuning laws below carry over directly:
|
||||
--
|
||||
-- 1. STATIC SAG. A spring holding mass m compresses by x = m*g/k. If that
|
||||
-- sag exceeds the available travel the suspension sits bottomed out, the
|
||||
-- spring stops doing any work, and the body rides on the constraint limit
|
||||
-- -- which reads as a twitchy, tippy car. The OLD constants (k=9000) gave
|
||||
-- 1.10 studs of sag against 0.90 studs of TOTAL travel: permanently
|
||||
-- bottomed. Now k is solved so the car rests at SAG_FRACTION of half
|
||||
-- travel, leaving real droop AND real compression headroom.
|
||||
--
|
||||
-- 2. DAMPING RATIO. c = zeta * 2*sqrt(k*m). Damping cannot be picked
|
||||
-- independently of stiffness -- it only means anything relative to
|
||||
-- critical damping. The old pair worked out to zeta = 0.31 (floaty,
|
||||
-- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within
|
||||
-- that band by Handling so a grippy car is also a composed one.
|
||||
local GRAVITY = 196.2 -- studs/s^2 (Roblox default)
|
||||
local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners
|
||||
local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel
|
||||
|
||||
function Chassis.tuningFor(car: Car): Tuning
|
||||
local maxSpeed = 70 + car.Stats.Speed * 1.5
|
||||
local handling01 = car.Stats.Handling / 100
|
||||
|
||||
-- Solve stiffness from the sag target, then stiffen with Handling.
|
||||
local halfTravel = Chassis.SUSPENSION_TRAVEL / 2
|
||||
local baseK = CORNER_MASS * GRAVITY / (halfTravel * SAG_FRACTION)
|
||||
local stiffness = baseK * (0.9 + handling01 * 0.45) -- ~28k soft .. ~41k stiff
|
||||
|
||||
-- Damping ratio 0.45 (compliant) -> 0.65 (buttoned down) by Handling.
|
||||
local zeta = 0.45 + handling01 * 0.20
|
||||
local damping = zeta * 2 * math.sqrt(stiffness * CORNER_MASS)
|
||||
|
||||
return {
|
||||
MaxSpeed = maxSpeed,
|
||||
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
|
||||
@@ -61,8 +95,8 @@ function Chassis.tuningFor(car: Car): Tuning
|
||||
BrakeTorque = 30000,
|
||||
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
|
||||
SteerSpeed = 6,
|
||||
SpringStiffness = 9000,
|
||||
SpringDamping = 420,
|
||||
SpringStiffness = stiffness,
|
||||
SpringDamping = damping,
|
||||
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
|
||||
}
|
||||
end
|
||||
|
||||
@@ -948,10 +948,17 @@ function Circuit.gap(progressA: number, progressB: number): number
|
||||
end
|
||||
|
||||
-- Is this world position inside the PA (off the racing surface)?
|
||||
-- PA containment. This MUST match the actual PAFloor geometry, which MapBuilder
|
||||
-- builds as a SQUARE slab of size (PA_RADIUS*2 x PA_RADIUS*2). A circular test
|
||||
-- reports false for the four corners -- up to PA_RADIUS*sqrt(2) (~367) from the
|
||||
-- centre -- even though the car is physically parked on the floor. Because
|
||||
-- ModeManager uses inPA to arm/start/finish a run, a car sitting in a corner
|
||||
-- looked like it had "left the parking area" and spuriously started a run.
|
||||
-- Square (Chebyshev) test, matching the slab.
|
||||
function Circuit.inPA(pos: Vector3): boolean
|
||||
local c = Circuit.PA_CENTER
|
||||
local dx, dz = pos.X - c.X, pos.Z - c.Z
|
||||
return (dx * dx + dz * dz) <= (Circuit.PA_RADIUS * Circuit.PA_RADIUS)
|
||||
local dx, dz = math.abs(pos.X - c.X), math.abs(pos.Z - c.Z)
|
||||
return dx <= Circuit.PA_RADIUS and dz <= Circuit.PA_RADIUS
|
||||
end
|
||||
|
||||
-- Forward direction (unit) of the racing line at a sample index.
|
||||
|
||||
Reference in New Issue
Block a user