CI / quality (push) Failing after 47s
The stale-loop check indexed seatPart without a nil guard:
not seatPart:IsDescendantOf(workspace)
On respawn CarManager destroys the old car, so humanoid.SeatPart is nil for a
frame while heartbeatConn is still live. That path then indexed a nil value,
the error propagated out of the task.spawn thread, and the watchdog - the only
self-healing path in the controller - DIED PERMANENTLY. The car was undrivable
from then on, and the bug looked intermittent because the edge-triggered
SeatPart signal still covered the simple nil -> seat transition.
Nil-check seatPart before indexing, and wrap the whole watchdog body in pcall
so no future error can ever silently kill the loop again (warn instead).
Verified: 5 consecutive respawns then drives to MaxSpeed 163 studs/s at road
level (up=1.00), TIME_ATTACK sessionKm advances 0.003 -> 0.169 km.
202 lines
7.1 KiB
Luau
202 lines
7.1 KiB
Luau
--!strict
|
|
--[[
|
|
ChassisController.luau (CLIENT)
|
|
Drives the constraint chassis while the local player is in a DriveSeat.
|
|
|
|
Why client-side: the server gives the driver network ownership of the car,
|
|
so physics (and constraint targets) run authoritatively on THIS client —
|
|
that's what makes driving feel responsive instead of laggy. The server
|
|
still owns WHICH car you get (allowlist etc.); this module only turns
|
|
seat input into motor/servo targets.
|
|
|
|
Loop per Heartbeat while seated:
|
|
Steer -> front servo TargetAngle (speed-sensitive: less lock when fast)
|
|
Throttle-> wheel motor AngularVelocity + MotorMaxTorque
|
|
No input-> brake torque toward zero angular velocity
|
|
]]
|
|
|
|
local Players = game:GetService("Players")
|
|
local RunService = game:GetService("RunService")
|
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
|
|
local Shared = ReplicatedStorage.Shared
|
|
local Cars = require(Shared.Cars)
|
|
local Chassis = require(Shared.Chassis)
|
|
|
|
local player = Players.LocalPlayer
|
|
|
|
local ChassisController = {}
|
|
|
|
local heartbeatConn: RBXScriptConnection? = nil
|
|
local seatedConn: RBXScriptConnection? = nil
|
|
-- The seat the live heartbeatConn is bound to. On respawn the old car is
|
|
-- destroyed and a new one spawned; the connection stays alive but drives the
|
|
-- DESTROYED car's motors. Tracking the seat lets the watchdog notice that the
|
|
-- loop is stale and re-arm against the new car.
|
|
local drivingSeat: VehicleSeat? = nil
|
|
|
|
local function findCarModel(seat: VehicleSeat): Model?
|
|
local model = seat:FindFirstAncestorOfClass("Model")
|
|
if model and model:GetAttribute("CarId") ~= nil then
|
|
return model
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function collectConstraints(model: Model): ({ HingeConstraint }, { HingeConstraint })
|
|
local motors: { HingeConstraint } = {}
|
|
local servos: { HingeConstraint } = {}
|
|
for _, desc in ipairs(model:GetDescendants()) do
|
|
if desc:IsA("HingeConstraint") then
|
|
if desc.Name == Chassis.WHEEL_MOTOR_NAME then
|
|
table.insert(motors, desc)
|
|
elseif desc.Name == Chassis.STEER_SERVO_NAME then
|
|
table.insert(servos, desc)
|
|
end
|
|
end
|
|
end
|
|
return motors, servos
|
|
end
|
|
|
|
local function startDriving(seat: VehicleSeat)
|
|
local model = findCarModel(seat)
|
|
if not model then
|
|
return
|
|
end
|
|
local carId = model:GetAttribute("CarId") :: string
|
|
local car = Cars.Get(carId)
|
|
if not car then
|
|
return
|
|
end
|
|
local tuning = Chassis.tuningFor(car)
|
|
local motors, servos = collectConstraints(model)
|
|
local body = model.PrimaryPart
|
|
if #motors == 0 or not body then
|
|
return
|
|
end
|
|
|
|
drivingSeat = seat
|
|
heartbeatConn = RunService.Heartbeat:Connect(function()
|
|
local throttle = seat.ThrottleFloat
|
|
local steer = seat.SteerFloat
|
|
|
|
-- Steering: speed-sensitive — at top speed only ~40% of full lock,
|
|
-- so the car doesn't spin out from a tap of the key at 150 studs/s.
|
|
local speed = body.AssemblyLinearVelocity.Magnitude
|
|
local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1)
|
|
local lockScale = 1 - 0.6 * speedFrac
|
|
local targetAngle = Chassis.STEER_SIGN * steer * tuning.MaxSteerAngle * lockScale
|
|
for _, servo in ipairs(servos) do
|
|
servo.TargetAngle = targetAngle
|
|
end
|
|
|
|
-- Drive/brake:
|
|
if math.abs(throttle) > 0.05 then
|
|
local targetVel = Chassis.DRIVE_SIGN * throttle * tuning.MaxWheelAngularVelocity
|
|
for _, motor in ipairs(motors) do
|
|
motor.AngularVelocity = targetVel
|
|
motor.MotorMaxTorque = tuning.MotorMaxTorque
|
|
end
|
|
else
|
|
-- Engine braking: drive wheel speed toward zero with brake torque.
|
|
for _, motor in ipairs(motors) do
|
|
motor.AngularVelocity = 0
|
|
motor.MotorMaxTorque = tuning.BrakeTorque
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function stopDriving()
|
|
if heartbeatConn then
|
|
heartbeatConn:Disconnect()
|
|
heartbeatConn = nil
|
|
end
|
|
drivingSeat = nil
|
|
end
|
|
|
|
function ChassisController.init()
|
|
-- SeatPart changes whenever the humanoid sits down or gets up.
|
|
local function onCharacter(character: Model)
|
|
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
|
|
if seatedConn then
|
|
seatedConn:Disconnect()
|
|
end
|
|
local function applySeat()
|
|
stopDriving()
|
|
local seatPart = humanoid.SeatPart
|
|
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
|
|
startDriving(seatPart)
|
|
end
|
|
end
|
|
|
|
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(applySeat)
|
|
|
|
-- WATCHDOG: the SeatPart signal alone is NOT reliable enough to arm the
|
|
-- chassis. Observed on cold boot: humanoid is Seated in a valid DriveSeat,
|
|
-- the car is network-owned by us, and the catch-up applySeat() below has
|
|
-- already run — yet the Heartbeat loop is not connected, so every motor
|
|
-- keeps MotorMaxTorque = 0 and the car is completely undrivable. The
|
|
-- signal fires during client bootstrap while modules are still being
|
|
-- required, and the edge is simply lost; re-running applySeat() once at
|
|
-- init doesn't help when the seat is assigned a moment AFTER that call.
|
|
-- A level-triggered check is immune to every ordering of these events:
|
|
-- if we are seated in a DriveSeat but have no live loop, arm it.
|
|
task.spawn(function()
|
|
while humanoid.Parent do
|
|
-- The whole body is pcall-wrapped: this watchdog is the ONLY
|
|
-- self-healing path. If it ever errors the thread dies silently
|
|
-- and the car becomes permanently undrivable, with the failure
|
|
-- appearing intermittent because the edge-triggered seat signal
|
|
-- still handles the easy nil -> seat case. Never let it die.
|
|
local ok, err = pcall(function()
|
|
local seatPart = humanoid.SeatPart
|
|
local seated = seatPart ~= nil
|
|
and seatPart:IsA("VehicleSeat")
|
|
and seatPart.Name == Chassis.SEAT_NAME
|
|
-- A live connection bound to a different seat (or to a car
|
|
-- that has since been destroyed) is STALE: it keeps driving
|
|
-- the old car's motors, so heartbeatConn stays non-nil and
|
|
-- the new car never gets armed. Re-arm whenever the bound
|
|
-- seat isn't the one we're sitting in, or it left the world.
|
|
-- NOTE: seatPart is nil for a frame whenever the old car is
|
|
-- destroyed on respawn, so it MUST be nil-checked before it
|
|
-- is indexed -- that crash is what killed this loop before.
|
|
local stale = heartbeatConn ~= nil
|
|
and (
|
|
drivingSeat ~= seatPart
|
|
or seatPart == nil
|
|
or not seatPart:IsDescendantOf(workspace)
|
|
)
|
|
if seated and (not heartbeatConn or stale) then
|
|
stopDriving()
|
|
startDriving(seatPart :: VehicleSeat)
|
|
elseif not seated and heartbeatConn then
|
|
stopDriving()
|
|
end
|
|
end)
|
|
if not ok then
|
|
warn("[ChassisController] watchdog iteration failed: " .. tostring(err))
|
|
end
|
|
task.wait(0.5)
|
|
end
|
|
end)
|
|
|
|
-- RACE FIX: the server can seat the player in a spawned car BEFORE this
|
|
-- listener exists (CarManager sits them during spawn, which may complete
|
|
-- before the client finishes requiring its modules). In that case the
|
|
-- SeatPart signal already fired with nobody listening, so startDriving
|
|
-- never ran and the motors kept MotorMaxTorque = 0 — the car looked
|
|
-- completely dead to the player. Evaluate the CURRENT seat state once at
|
|
-- init instead of only reacting to future changes.
|
|
applySeat()
|
|
end
|
|
|
|
if player.Character then
|
|
onCharacter(player.Character)
|
|
end
|
|
player.CharacterAdded:Connect(onCharacter)
|
|
end
|
|
|
|
return ChassisController
|