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()
|
function AntiCheat.init()
|
||||||
local function watch(player: Player)
|
local function watch(player: Player)
|
||||||
player.CharacterAdded:Connect(function(char)
|
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?
|
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
|
||||||
if hrp then
|
if hrp then
|
||||||
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
|
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)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ local SEAT_LIFT = 3
|
|||||||
-- The rig must be fully settled first or the client re-asserts a stale layout.
|
-- The rig must be fully settled first or the client re-asserts a stale layout.
|
||||||
local SETTLE_TIME = 1.0
|
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 ------------------------------------------------------------
|
-- Collision groups ------------------------------------------------------------
|
||||||
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
|
-- 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
|
-- 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 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)
|
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
||||||
CarBody.build(model, body, car)
|
CarBody.build(model, body, car)
|
||||||
|
end
|
||||||
|
|
||||||
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
|
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
|
||||||
ensureCollisionGroups()
|
ensureCollisionGroups()
|
||||||
@@ -428,9 +472,143 @@ function CarManager.init()
|
|||||||
carsFolder.Name = "ActiveCars"
|
carsFolder.Name = "ActiveCars"
|
||||||
carsFolder.Parent = Workspace
|
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)
|
Players.PlayerRemoving:Connect(function(player)
|
||||||
CarManager.despawn(player)
|
CarManager.despawn(player)
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
-- The player's live car model, or nil if they have none right now.
|
-- 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
|
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({
|
local pad = part({
|
||||||
Name = "SpawnPad",
|
Name = "SpawnPad",
|
||||||
Size = Vector3.new(120, 2.4, 120),
|
Size = Vector3.new(120, 2.4, 120),
|
||||||
|
|||||||
@@ -57,29 +57,77 @@ CarManager.init()
|
|||||||
ModeManager.init()
|
ModeManager.init()
|
||||||
AntiCheat.init()
|
AntiCheat.init()
|
||||||
|
|
||||||
-- 3. Build the world --------------------------------------------------------
|
-- 3. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||||
MapBuilder.build()
|
-- SPAWN FIX: characters must NOT auto-load. MapBuilder.build() yields for
|
||||||
Ambience.init()
|
-- 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)
|
ModeManager.onPlayerAdded(player)
|
||||||
DataManager.onReady(player, function()
|
DataManager.onReady(player, function()
|
||||||
pushCar(player)
|
pushCar(player)
|
||||||
ModeManager.pushTo(player)
|
ModeManager.pushTo(player)
|
||||||
end)
|
end)
|
||||||
player.CharacterAdded:Connect(function()
|
player.CharacterAdded:Connect(function(character)
|
||||||
DataManager.onReady(player, function(data)
|
DataManager.onReady(player, function(data)
|
||||||
task.wait(1)
|
task.wait(1)
|
||||||
CarManager.spawn(player, data.EquippedCarId)
|
CarManager.spawn(player, data.EquippedCarId)
|
||||||
end)
|
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)
|
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)
|
Players.PlayerRemoving:Connect(function(player)
|
||||||
|
handled[player] = nil
|
||||||
ModeManager.onPlayerRemoving(player)
|
ModeManager.onPlayerRemoving(player)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- 4. Build the world --------------------------------------------------------
|
||||||
|
MapBuilder.build()
|
||||||
|
Ambience.init()
|
||||||
|
mapReady = true
|
||||||
|
|
||||||
-- 5. Remote handlers (validated server-side) --------------------------------
|
-- 5. Remote handlers (validated server-side) --------------------------------
|
||||||
rSpawnCar.OnServerEvent:Connect(function(player)
|
rSpawnCar.OnServerEvent:Connect(function(player)
|
||||||
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
||||||
|
|||||||
+36
-2
@@ -52,8 +52,42 @@ export type Tuning = {
|
|||||||
WheelFriction: number,
|
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
|
function Chassis.tuningFor(car: Car): Tuning
|
||||||
local maxSpeed = 70 + car.Stats.Speed * 1.5
|
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 {
|
return {
|
||||||
MaxSpeed = maxSpeed,
|
MaxSpeed = maxSpeed,
|
||||||
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
|
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
|
||||||
@@ -61,8 +95,8 @@ function Chassis.tuningFor(car: Car): Tuning
|
|||||||
BrakeTorque = 30000,
|
BrakeTorque = 30000,
|
||||||
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
|
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
|
||||||
SteerSpeed = 6,
|
SteerSpeed = 6,
|
||||||
SpringStiffness = 9000,
|
SpringStiffness = stiffness,
|
||||||
SpringDamping = 420,
|
SpringDamping = damping,
|
||||||
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
|
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -948,10 +948,17 @@ function Circuit.gap(progressA: number, progressB: number): number
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Is this world position inside the PA (off the racing surface)?
|
-- 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
|
function Circuit.inPA(pos: Vector3): boolean
|
||||||
local c = Circuit.PA_CENTER
|
local c = Circuit.PA_CENTER
|
||||||
local dx, dz = pos.X - c.X, pos.Z - c.Z
|
local dx, dz = math.abs(pos.X - c.X), math.abs(pos.Z - c.Z)
|
||||||
return (dx * dx + dz * dz) <= (Circuit.PA_RADIUS * Circuit.PA_RADIUS)
|
return dx <= Circuit.PA_RADIUS and dz <= Circuit.PA_RADIUS
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Forward direction (unit) of the racing line at a sample index.
|
-- Forward direction (unit) of the racing line at a sample index.
|
||||||
|
|||||||
Reference in New Issue
Block a user