CI / quality (push) Failing after 53s
The drive loop was armed purely edge-triggered from
humanoid:GetPropertyChangedSignal("SeatPart") plus a one-shot catch-up
applySeat() at init. On cold boot the server seats the player AFTER that
catch-up runs, so the edge is lost, startDriving() never connects, and all
four wheel motors stay at MotorMaxTorque = 0 - the car is completely
undrivable until the player manually gets out and back in.
Replace the one-shot with a 0.5s level-triggered watchdog: if seated in a
DriveSeat with no live heartbeat connection, arm it; if not seated with a
live connection, stop it. Immune to any ordering of bootstrap vs seating.
Verified on cold boot (no manual reseat): all 4 motors uniform, poisoned
torque values restored within 0.6s, car accelerates to MaxSpeed 163 studs/s,
holds it upright, and engine-brakes 163 -> 0.
170 lines
5.6 KiB
Luau
170 lines
5.6 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
|
|
|
|
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
|
|
|
|
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
|
|
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
|
|
local seatPart = humanoid.SeatPart
|
|
local seated = seatPart
|
|
and seatPart:IsA("VehicleSeat")
|
|
and seatPart.Name == Chassis.SEAT_NAME
|
|
if seated and not heartbeatConn then
|
|
startDriving(seatPart :: VehicleSeat)
|
|
elseif not seated and heartbeatConn then
|
|
stopDriving()
|
|
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
|