CI / quality (push) Failing after 47s
Respawning left the car undrivable. CarManager.spawn() destroys the old car and builds a new one, but the client's Heartbeat connection survived and kept driving the DESTROYED car's motors. Because heartbeatConn stayed non-nil, the cold-start watchdog concluded the chassis was already armed and never armed the new car - torque stayed at the idle brake value and the car never moved. Track the seat the live loop is bound to (drivingSeat) and treat the loop as stale when that seat isn't the one we're sitting in, or its car has left the workspace. On stale, stop and re-arm against the new car. Verified: after 4 respawn cycles the car accelerates 49 -> 163 studs/s, travels 273 studs, stays upright at road level.
185 lines
6.4 KiB
Luau
185 lines
6.4 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
|
|
local seatPart = humanoid.SeatPart
|
|
local seated = seatPart
|
|
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 actually sitting in, or its car left the world.
|
|
local stale = heartbeatConn ~= nil
|
|
and (drivingSeat ~= seatPart 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
|
|
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
|