M1: constraint chassis (springs, servo steering, motor wheels)
CI / quality (push) Successful in 54s

- New shared Chassis.luau: geometry constants + per-car tuning from stats
- CarManager.buildCarModel rebuilt: prismatic+spring suspension per corner,
  servo-hinge steering on front knuckles, motor-hinge driven wheels,
  hidden dense ballast for anti-roll; VehicleSeat kept as input device only
- New client ChassisController: drives motors/servos from seat
  ThrottleFloat/SteerFloat per Heartbeat; speed-sensitive steering lock;
  engine braking on throttle release
- Driver gets network ownership of every unanchored part (constraint
  assemblies are separate; missing one causes stuttery physics)
- Docs: MESH_SWAP.md rewritten for shell-on-chassis workflow; ROADMAP updated
This commit is contained in:
2026-07-22 00:20:59 -04:00
parent 07a67204ca
commit b3a1044df6
6 changed files with 482 additions and 97 deletions
+133
View File
@@ -0,0 +1,133 @@
--!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
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
stopDriving()
local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startDriving(seatPart)
end
end)
end
if player.Character then
onCharacter(player.Character)
end
player.CharacterAdded:Connect(onCharacter)
end
return ChassisController
+3
View File
@@ -15,6 +15,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Remotes = require(Shared.Remotes)
local Cars = require(Shared.Cars)
local ChassisController = require(script.ChassisController)
ChassisController.init()
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")