Sit() dragged the seat's body assembly ~15,700 studs to the client-owned character while the knuckle/wheel assemblies stayed at the spawn pad, tearing the car apart; wheels then fell out of the world. Server-side character:PivotTo() does not stick (client has authority) -- it holds one frame and snaps back, so the pre-seat teleport could not fix this. Instead: record each part's offset from the body while the model is intact, take server network ownership, let Sit() and the snap-back settle, then reassemble every assembly around the body (forced level) and hand ownership back to the driver. Verified in Studio: max part separation 6.42 studs (exhaust, normal), up-vector 1.00, driver seated, drove 98.6 studs at 41 studs/s intact.
468 lines
16 KiB
Luau
468 lines
16 KiB
Luau
--!strict
|
|
--[[
|
|
CarManager.luau (SERVER)
|
|
Spawns the player's equipped car as a CONSTRAINT CHASSIS vehicle.
|
|
|
|
M1 upgrade: the old single-Part + VehicleSeat arcade car is replaced with a
|
|
proper physical chassis (A-Chassis style, built from Roblox constraints):
|
|
|
|
Body ──Prismatic(vertical)+Spring──> Hub ──[front only: Servo hinge]──>
|
|
Knuckle ──Motor hinge──> Wheel
|
|
|
|
- Springs give real suspension; prismatics constrain travel to vertical.
|
|
- Front knuckles yaw on Servo hinges (steering).
|
|
- Every wheel spins on a Motor hinge (drive/brake).
|
|
- The VehicleSeat is kept ONLY as the input device: its Throttle/Steer
|
|
are read by the client ChassisController, which sets motor/servo
|
|
targets locally (the driver owns the physics, so it replicates).
|
|
|
|
The real RX-7 mesh swap (docs/MESH_SWAP.md) welds a MeshPart onto the Body
|
|
and hides the placeholder blocks — the chassis itself doesn't change.
|
|
]]
|
|
|
|
local Players = game:GetService("Players")
|
|
local Workspace = game:GetService("Workspace")
|
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
|
|
local Shared = ReplicatedStorage.Shared
|
|
local Cars = require(Shared.Cars)
|
|
local Chassis = require(Shared.Chassis)
|
|
local GameConfig = require(Shared.GameConfig)
|
|
local DataManager = require(script.Parent.DataManager)
|
|
local CarBody = require(script.Parent.CarBody)
|
|
local AntiCheat = require(script.Parent.AntiCheat)
|
|
local PhysicsService = game:GetService("PhysicsService")
|
|
|
|
type Car = Cars.Car
|
|
type Tuning = Chassis.Tuning
|
|
|
|
local CarManager = {}
|
|
|
|
local activeCars: { [Player]: Model } = {}
|
|
local carsFolder: Folder
|
|
|
|
-- Collision groups ------------------------------------------------------------
|
|
-- 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
|
|
-- its inner edge at 2.80 -- a 0.2 stud lateral interpenetration. Vertically the
|
|
-- wheel top reaches -0.2 against a body bottom of -0.7. Left in the same
|
|
-- collision group the wheels grind against the body and the car will not roll
|
|
-- (motors spin at ~1 rad/s against 20,000 torque). Putting wheels in their own
|
|
-- group that ignores the chassis is the fix; the geometry is intentional.
|
|
local WHEEL_GROUP = "CarWheel"
|
|
local CHASSIS_GROUP = "CarChassis"
|
|
local groupsReady = false
|
|
|
|
local function ensureCollisionGroups()
|
|
if groupsReady then
|
|
return
|
|
end
|
|
groupsReady = true
|
|
local ok = pcall(function()
|
|
PhysicsService:RegisterCollisionGroup(WHEEL_GROUP)
|
|
PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP)
|
|
end)
|
|
if not ok then
|
|
-- Already registered (Play-solo re-entry) -- harmless.
|
|
end
|
|
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false)
|
|
PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true)
|
|
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false)
|
|
end
|
|
|
|
-- Helpers ---------------------------------------------------------------------
|
|
|
|
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
|
|
local att = Instance.new("Attachment")
|
|
att.Name = name
|
|
att.CFrame = cf
|
|
att.Parent = parent
|
|
return att
|
|
end
|
|
|
|
-- Attachment whose primary (X) axis points straight up — used for vertical
|
|
-- prismatic travel and for the steering servo's yaw axis.
|
|
local VERTICAL_AXIS = CFrame.Angles(0, 0, math.rad(90))
|
|
|
|
local function makePart(props: {
|
|
Name: string,
|
|
Size: Vector3,
|
|
CFrame: CFrame,
|
|
Color: Color3?,
|
|
Material: Enum.Material?,
|
|
CanCollide: boolean,
|
|
Parent: Instance,
|
|
Shape: Enum.PartType?,
|
|
Friction: number?,
|
|
}): Part
|
|
local part = Instance.new("Part")
|
|
part.Name = props.Name
|
|
part.Size = props.Size
|
|
part.CFrame = props.CFrame
|
|
if props.Shape then
|
|
part.Shape = props.Shape
|
|
end
|
|
part.Color = props.Color or Color3.new(0.2, 0.2, 0.2)
|
|
part.Material = props.Material or Enum.Material.SmoothPlastic
|
|
part.CanCollide = props.CanCollide
|
|
if props.Friction then
|
|
part.CustomPhysicalProperties = PhysicalProperties.new(0.9, props.Friction, 0.2, 1, 1)
|
|
end
|
|
part.Parent = props.Parent
|
|
return part
|
|
end
|
|
|
|
-- Build one corner of the suspension. `isFront` corners get a steering servo.
|
|
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
|
|
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
|
|
local isFront = zSign < 0 -- car forward is -Z
|
|
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
|
|
|
|
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
|
|
local wheelWorld = body.CFrame * CFrame.new(wheelLocal)
|
|
|
|
-- Hub: rides the prismatic+spring below the body corner ---------------------
|
|
local hub = makePart({
|
|
Name = "Hub_" .. cornerName,
|
|
Size = Vector3.new(0.6, 0.6, 0.6),
|
|
CFrame = wheelWorld,
|
|
CanCollide = false,
|
|
Parent = model,
|
|
})
|
|
|
|
local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS)
|
|
local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS)
|
|
|
|
-- Rigid hub mount. A PrismaticConstraint+SpringConstraint suspension was used
|
|
-- here, but the hub (0.6 cube) is ~3 orders of magnitude lighter than the
|
|
-- ballasted body, and that mass ratio across a stiff spring makes the solver
|
|
-- diverge: the hub/knuckle/wheel assemblies get flung to ~1e7 studs, fall
|
|
-- past FallenPartsDestroyHeight and are deleted -- leaving a car with a body
|
|
-- and no wheels that cannot move. Welding the hub makes it part of the body
|
|
-- assembly, which is rock stable. Ride is rigid; the car actually drives.
|
|
local hubWeld = Instance.new("WeldConstraint")
|
|
hubWeld.Name = "SuspWeld_" .. cornerName
|
|
hubWeld.Part0 = body
|
|
hubWeld.Part1 = hub
|
|
hubWeld.Parent = body
|
|
|
|
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
|
|
local wheelParent: Part = hub
|
|
if isFront then
|
|
local knuckle = makePart({
|
|
Name = "Knuckle_" .. cornerName,
|
|
Size = Vector3.new(0.5, 0.5, 0.5),
|
|
CFrame = wheelWorld,
|
|
CanCollide = false,
|
|
Parent = model,
|
|
})
|
|
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
|
|
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
|
|
|
|
local servo = Instance.new("HingeConstraint")
|
|
servo.Name = Chassis.STEER_SERVO_NAME
|
|
servo.Attachment0 = hubSteerAtt
|
|
servo.Attachment1 = knuckleAtt
|
|
servo.ActuatorType = Enum.ActuatorType.Servo
|
|
servo.ServoMaxTorque = 1e6
|
|
servo.AngularSpeed = tuning.SteerSpeed
|
|
servo.TargetAngle = 0
|
|
servo.LimitsEnabled = true
|
|
servo.LowerAngle = -tuning.MaxSteerAngle
|
|
servo.UpperAngle = tuning.MaxSteerAngle
|
|
servo.Parent = hub
|
|
|
|
wheelParent = knuckle
|
|
end
|
|
|
|
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
|
|
local wheel = makePart({
|
|
Name = "Wheel_" .. cornerName,
|
|
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
|
|
CFrame = wheelWorld,
|
|
Shape = Enum.PartType.Cylinder, -- cylinder axis = X = lateral. correct spin axis
|
|
Color = Color3.new(0.08, 0.08, 0.09),
|
|
Material = Enum.Material.Rubber,
|
|
CanCollide = true,
|
|
Friction = tuning.WheelFriction,
|
|
Parent = model,
|
|
})
|
|
|
|
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
|
|
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
|
|
|
|
local motor = Instance.new("HingeConstraint")
|
|
motor.Name = Chassis.WHEEL_MOTOR_NAME
|
|
motor.Attachment0 = parentAtt
|
|
motor.Attachment1 = wheelAtt
|
|
motor.ActuatorType = Enum.ActuatorType.Motor
|
|
motor.MotorMaxTorque = 0 -- freewheel until the ChassisController takes over
|
|
motor.AngularVelocity = 0
|
|
motor.Parent = wheelParent
|
|
end
|
|
|
|
-- Build the full constraint-chassis car model.
|
|
local function buildCarModel(car: Car, tuning: Tuning): Model
|
|
local model = Instance.new("Model")
|
|
model.Name = "Car_" .. car.Id
|
|
|
|
local origin = CFrame.new() -- built at origin; PivotTo() places it later
|
|
|
|
-- Body (PrimaryPart / mesh anchor point) ------------------------------------
|
|
local body = makePart({
|
|
Name = "Body",
|
|
Size = Chassis.BODY_SIZE,
|
|
CFrame = origin,
|
|
Color = Color3.new(car.Color[1], car.Color[2], car.Color[3]),
|
|
CanCollide = true,
|
|
Parent = model,
|
|
})
|
|
|
|
-- Ballast: dense low block welded under the body — drops the centre of mass
|
|
-- so the car corners flat instead of rolling over.
|
|
local ballast = makePart({
|
|
Name = "Ballast",
|
|
Size = Vector3.new(4, 0.4, 8),
|
|
CFrame = origin * CFrame.new(0, -0.9, 0),
|
|
CanCollide = false,
|
|
Parent = model,
|
|
})
|
|
ballast.Transparency = 1
|
|
ballast.CustomPhysicalProperties = PhysicalProperties.new(8, 0.3, 0.5, 1, 1)
|
|
local ballastWeld = Instance.new("WeldConstraint")
|
|
ballastWeld.Part0 = body
|
|
ballastWeld.Part1 = ballast
|
|
ballastWeld.Parent = body
|
|
|
|
-- Visual bodywork (nose, glass, spoilers, lights, underglow, rims) is added
|
|
-- by CarBody.build() after assembly. All CarBody parts are Massless, so
|
|
-- they never alter the chassis physics.
|
|
|
|
-- Seat: input device only (Throttle/Steer). It drives nothing directly. -----
|
|
local seat = Instance.new("VehicleSeat")
|
|
seat.Name = Chassis.SEAT_NAME
|
|
seat.Size = Vector3.new(4, 0.5, 3)
|
|
seat.MaxSpeed = 0 -- neutralise built-in drive; constraints do the work
|
|
seat.Torque = 0
|
|
seat.TurnSpeed = 0
|
|
seat.HeadsUpDisplay = false
|
|
seat.CanCollide = false
|
|
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
|
-- Seat pan just above the body slab (top = H/2): low enough to look tucked
|
|
-- in the cabin, high enough that the avatar's legs never interpenetrate
|
|
-- the slab (which causes physics launches).
|
|
seat.CFrame = origin * CFrame.new(0, Chassis.BODY_SIZE.Y / 2 + 0.3, 0.2)
|
|
seat.Parent = model
|
|
local seatWeld = Instance.new("WeldConstraint")
|
|
seatWeld.Part0 = body
|
|
seatWeld.Part1 = seat
|
|
seatWeld.Parent = body
|
|
|
|
-- Four suspension corners ---------------------------------------------------
|
|
for _, xSign in ipairs({ -1, 1 }) do
|
|
for _, zSign in ipairs({ -1, 1 }) do
|
|
buildCorner(model, body, tuning, xSign, zSign)
|
|
end
|
|
end
|
|
|
|
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
|
CarBody.build(model, body, car)
|
|
|
|
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
|
|
ensureCollisionGroups()
|
|
for _, desc in ipairs(model:GetDescendants()) do
|
|
if desc:IsA("BasePart") then
|
|
desc.CollisionGroup = if desc.Name:match("^Wheel_") then WHEEL_GROUP else CHASSIS_GROUP
|
|
end
|
|
end
|
|
|
|
model.PrimaryPart = body
|
|
return model
|
|
end
|
|
|
|
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
|
|
-- CharacterAdded can fire before MapBuilder.build() has finished, in which case
|
|
-- a naive lookup silently falls back to the origin and dumps the car ~8,600
|
|
-- studs off-circuit. Wait briefly for the pad rather than guessing.
|
|
local function spawnCFrame(): CFrame
|
|
local deadline = os.clock() + 10
|
|
repeat
|
|
local map = Workspace:FindFirstChild("Map")
|
|
local pad = map and map:FindFirstChild("SpawnPad", true)
|
|
if pad and pad:IsA("BasePart") then
|
|
return pad.CFrame * CFrame.new(0, 5, 0)
|
|
end
|
|
task.wait(0.1)
|
|
until os.clock() > deadline
|
|
warn("[CarManager] SpawnPad never appeared; falling back to origin.")
|
|
return CFrame.new(0, 8, 0)
|
|
end
|
|
|
|
--- Despawn the player's current car if any.
|
|
function CarManager.despawn(player: Player)
|
|
local existing = activeCars[player]
|
|
if existing then
|
|
existing:Destroy()
|
|
activeCars[player] = nil
|
|
end
|
|
end
|
|
|
|
--- Spawn (or replace) the player's equipped car and seat them in it.
|
|
function CarManager.spawn(player: Player, carId: string?)
|
|
local data = DataManager.get(player)
|
|
if not data then
|
|
return
|
|
end
|
|
local id = carId or data.EquippedCarId
|
|
local car = Cars.Get(id)
|
|
if not car then
|
|
return
|
|
end
|
|
-- Restricted car (RX-7) can only be driven by allowlisted UserIds.
|
|
if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then
|
|
return
|
|
end
|
|
|
|
CarManager.despawn(player)
|
|
|
|
local tuning = Chassis.tuningFor(car)
|
|
local model = buildCarModel(car, tuning)
|
|
local target = spawnCFrame()
|
|
model.Parent = carsFolder
|
|
-- Pivot AFTER parenting: pivoting an unparented model then reparenting lets
|
|
-- the physics solver re-resolve from the model's build origin, which drops
|
|
-- the car back at (0,0,0) instead of the spawn pad.
|
|
model:PivotTo(target)
|
|
model:SetAttribute("OwnerUserId", player.UserId)
|
|
model:SetAttribute("CarId", id)
|
|
|
|
-- Give the driver network ownership of EVERY assembly in the car. With a
|
|
-- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE
|
|
-- assemblies (constraints don't merge them like welds do) — missing one
|
|
-- causes stuttery mixed-authority physics.
|
|
for _, desc in ipairs(model:GetDescendants()) do
|
|
if desc:IsA("BasePart") and not desc.Anchored then
|
|
desc:SetNetworkOwner(player)
|
|
end
|
|
end
|
|
|
|
local character = player.Character
|
|
if character then
|
|
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat
|
|
if seat then
|
|
-- The character is CLIENT-owned, so a server-side PivotTo of it does
|
|
-- NOT stick: it holds for one frame and the client snaps it back to
|
|
-- where it was. Sit() then drags the seat's assembly across the map
|
|
-- to reach the character -- and because a constraint chassis keeps
|
|
-- the body, knuckles and wheels as SEPARATE assemblies, only the
|
|
-- body follows. The knuckles/wheels stay behind at the pad and the
|
|
-- car is torn apart (measured ~15,700 studs), later falling out of
|
|
-- the world.
|
|
--
|
|
-- Fix: don't fight the client. Record every part's offset from the
|
|
-- body while the model is still intact, let Sit() and the character
|
|
-- snap-back fully settle, then REASSEMBLE the car around wherever
|
|
-- the body actually ended up.
|
|
local primary = model.PrimaryPart
|
|
local offsets: { [BasePart]: CFrame } = {}
|
|
local movable: { BasePart } = {}
|
|
if primary then
|
|
local inverse = primary.CFrame:Inverse()
|
|
for _, desc in ipairs(model:GetDescendants()) do
|
|
if desc:IsA("BasePart") and not desc.Anchored then
|
|
offsets[desc] = inverse * desc.CFrame
|
|
table.insert(movable, desc)
|
|
end
|
|
end
|
|
end
|
|
|
|
AntiCheat.grant(player)
|
|
character:PivotTo(target * CFrame.new(0, 3, 0))
|
|
task.defer(function()
|
|
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
|
|
|
|
if not primary then
|
|
return
|
|
end
|
|
-- Hold server authority while the rig is in pieces, otherwise
|
|
-- the client re-asserts the torn layout over our repair.
|
|
for _, part in ipairs(movable) do
|
|
pcall(function()
|
|
part:SetNetworkOwner(nil)
|
|
end)
|
|
end
|
|
|
|
task.wait(1.2) -- Sit() + client snap-back settle here
|
|
|
|
if not primary:IsDescendantOf(Workspace) then
|
|
return
|
|
end
|
|
-- Rebuild every assembly around the body, forced level so the
|
|
-- car never lands on its roof.
|
|
local position = primary.Position
|
|
local look = primary.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 base = CFrame.lookAt(position, position + flat.Unit)
|
|
for _, part in ipairs(movable) do
|
|
if part:IsDescendantOf(Workspace) then
|
|
part.CFrame = base * offsets[part]
|
|
part.AssemblyLinearVelocity = Vector3.zero
|
|
part.AssemblyAngularVelocity = Vector3.zero
|
|
end
|
|
end
|
|
|
|
task.wait(0.3) -- let the solver accept the repaired rig
|
|
|
|
AntiCheat.grant(player)
|
|
for _, part in ipairs(movable) do
|
|
if part:IsDescendantOf(Workspace) then
|
|
pcall(function()
|
|
part:SetNetworkOwner(player)
|
|
end)
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
activeCars[player] = model
|
|
end
|
|
|
|
function CarManager.init()
|
|
carsFolder = Instance.new("Folder")
|
|
carsFolder.Name = "ActiveCars"
|
|
carsFolder.Parent = Workspace
|
|
|
|
Players.PlayerRemoving:Connect(function(player)
|
|
CarManager.despawn(player)
|
|
end)
|
|
end
|
|
|
|
-- The player's live car model, or nil if they have none right now.
|
|
function CarManager.getCar(player: Player): Model?
|
|
local model = activeCars[player]
|
|
if model and model.Parent then
|
|
return model
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- The physics root of the player's car (the chassis Body). ModeManager samples
|
|
-- this to measure real, server-side position — never client-reported.
|
|
function CarManager.getRoot(player: Player): BasePart?
|
|
local model = CarManager.getCar(player)
|
|
if not model then
|
|
return nil
|
|
end
|
|
local body = model:FindFirstChild("Body")
|
|
if body and body:IsA("BasePart") then
|
|
return body
|
|
end
|
|
return model.PrimaryPart
|
|
end
|
|
|
|
return CarManager
|