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:
+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.
|
||||
|
||||
Reference in New Issue
Block a user