Real car meshes + physics-derived suspension + inPA/void fixes
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:
Hermes
2026-07-31 19:04:32 -04:00
parent f70a0b06bb
commit 0c5ecd2487
7 changed files with 2050 additions and 13 deletions
+180 -2
View File
@@ -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.