M1: constraint chassis (springs, servo steering, motor wheels)
CI / quality (push) Successful in 54s
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:
+49
-43
@@ -1,11 +1,13 @@
|
||||
# Swapping the placeholder RX-7 for the real mesh (M1)
|
||||
|
||||
Right now every car is a coloured block + a `VehicleSeat` so the game is drivable
|
||||
today. This is the **one file** you touch to make the RX-7 a real model:
|
||||
`src/server/CarManager.luau` → the `buildCarModel` function.
|
||||
The car is now a **constraint chassis** (springs + steering servos + motor-driven
|
||||
wheels — see `src/shared/Chassis.luau` and `CarManager.buildCarModel`). The
|
||||
placeholder Body/Cabin blocks are just the visual shell riding on that chassis.
|
||||
Making the RX-7 real means **replacing the shell, not the chassis**: weld a
|
||||
MeshPart body onto the `Body` part and hide the placeholder blocks.
|
||||
|
||||
Nothing else (saving, the allowlist, car switching, the map, anti-cheat) needs to
|
||||
change. That's the whole point of isolating model-building in one function.
|
||||
Nothing else (saving, the allowlist, car switching, driving feel, the map,
|
||||
anti-cheat) needs to change.
|
||||
|
||||
## Reference photos
|
||||
The real car's reference photos live on the NAS (too large to commit to git):
|
||||
@@ -17,7 +19,7 @@ unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7
|
||||
A single downscaled reference is included at `assets/reference/rx7-reference.png`
|
||||
for quick lookups.
|
||||
|
||||
## Step 1 — Get a model into Roblox
|
||||
## Step 1 — Get a mesh into Roblox
|
||||
Options, easiest first:
|
||||
1. **Buy or find a free RX-7 FD model** on the Roblox Creator Marketplace/toolbox,
|
||||
or
|
||||
@@ -25,58 +27,62 @@ Options, easiest first:
|
||||
(`.fbx`/`.obj`) via Studio's 3D importer, or
|
||||
3. Commission one.
|
||||
|
||||
You want a single `Model` whose parts are welded together, with a clear "body"
|
||||
part you can drive. Keep the part count low (mobile perf).
|
||||
You want the car **body shell as a single MeshPart** (or a small welded Model
|
||||
with a clear root). Wheels can be separate meshes (see Step 4). Keep the part
|
||||
count low (mobile perf). This step needs Studio — mesh assets upload through
|
||||
Roblox's asset pipeline.
|
||||
|
||||
## Step 2 — Store it where Rojo/Studio can clone it
|
||||
Put the finished `Model` under `ReplicatedStorage` (e.g. a `CarModels` folder).
|
||||
Two ways to manage it:
|
||||
## Step 2 — Store it where the server can clone it
|
||||
Put the shell under `ReplicatedStorage/CarModels/<carId>` (e.g.
|
||||
`CarModels/rx7_fc`). Two ways to manage it:
|
||||
- **Simple:** keep it in the Studio place file as a real Instance (not synced by
|
||||
Rojo). Fine for a single hero car.
|
||||
- **Version-controlled:** export the model to a `.rbxm` and reference it from the
|
||||
Rojo project so it lives in git. See Rojo's `$path` docs for `.rbxm` model files.
|
||||
|
||||
## Step 3 — Rewrite `buildCarModel` to clone the real mesh
|
||||
Replace the block-building body of `buildCarModel(car, maxSpeed, torque, turnSpeed)`
|
||||
with something like:
|
||||
## Step 3 — Attach the shell in `buildCarModel`
|
||||
In `CarManager.buildCarModel`, after the Body part is created:
|
||||
|
||||
```lua
|
||||
local CAR_MODELS = ReplicatedStorage:WaitForChild("CarModels")
|
||||
local CAR_MODELS = ReplicatedStorage:FindFirstChild("CarModels")
|
||||
|
||||
local function buildCarModel(car, maxSpeed, torque, turnSpeed)
|
||||
-- Look up a real model by car id; fall back to the block builder if missing.
|
||||
local template = CAR_MODELS:FindFirstChild(car.Id)
|
||||
if not template then
|
||||
return buildBlockCar(car, maxSpeed, torque, turnSpeed) -- keep the old code as a fallback
|
||||
-- inside buildCarModel, after `body` exists:
|
||||
local shellTemplate = CAR_MODELS and CAR_MODELS:FindFirstChild(car.Id)
|
||||
if shellTemplate then
|
||||
local shell = shellTemplate:Clone()
|
||||
-- Position the shell over the body, then weld it on.
|
||||
shell:PivotTo(body.CFrame * CFrame.new(0, SHELL_Y_OFFSET, 0))
|
||||
for _, p in ipairs(shell:GetDescendants()) do
|
||||
if p:IsA("BasePart") then
|
||||
p.CanCollide = false -- the Body part remains the collider
|
||||
p.Anchored = false
|
||||
end
|
||||
|
||||
local model = template:Clone()
|
||||
|
||||
-- Ensure it has a VehicleSeat named "DriveSeat" welded to the driving body.
|
||||
local seat = model:FindFirstChild("DriveSeat")
|
||||
if not seat then
|
||||
seat = Instance.new("VehicleSeat")
|
||||
seat.Name = "DriveSeat"
|
||||
-- weld seat to model.PrimaryPart, position it in the cabin...
|
||||
seat.Parent = model
|
||||
end
|
||||
seat.MaxSpeed = maxSpeed
|
||||
seat.Torque = torque
|
||||
seat.TurnSpeed = turnSpeed
|
||||
|
||||
assert(model.PrimaryPart, "Car model needs a PrimaryPart set")
|
||||
return model
|
||||
local weld = Instance.new("WeldConstraint")
|
||||
weld.Part0 = body
|
||||
weld.Part1 = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart")
|
||||
weld.Parent = body
|
||||
shell.Parent = model
|
||||
-- Hide the placeholder blocks:
|
||||
body.Transparency = 1
|
||||
local cabin = model:FindFirstChild("Cabin")
|
||||
if cabin then cabin:Destroy() end
|
||||
end
|
||||
```
|
||||
|
||||
Keep the current block-builder renamed as `buildBlockCar` so any car without a
|
||||
real mesh still works. That way you can import cars one at a time.
|
||||
Cars without a mesh keep the block look automatically — import one car at a time.
|
||||
|
||||
## Step 4 (recommended, M1) — Move to a real chassis
|
||||
The `VehicleSeat` is great for "it drives today" but for real suspension/grip,
|
||||
adopt a proven **constraint chassis** (A-Chassis or a modern equivalent). At that
|
||||
point `buildCarModel` clones the chassis rig and attaches the RX-7 body to it.
|
||||
Still one function; still no changes elsewhere.
|
||||
## Step 4 (polish) — Real wheel meshes
|
||||
The chassis wheels are plain cylinders (`Wheel_FL` … `Wheel_RR`). To make them
|
||||
look real, weld a wheel MeshPart (CanCollide=false) onto each cylinder and set
|
||||
the cylinder's `Transparency = 1`. The cylinder stays the physics tyre — mesh
|
||||
collision on wheels is unreliable and slow.
|
||||
|
||||
## Sizing gotcha
|
||||
The chassis geometry (`Chassis.BODY_SIZE`, `WHEEL_RADIUS`, `TRACK_HALF`,
|
||||
`WHEELBASE_HALF`) is tuned for a ~6×12 stud footprint. Scale the imported mesh
|
||||
to fit the chassis — don't reshape the chassis around the mesh, or the driving
|
||||
feel changes for every car.
|
||||
|
||||
## Verify
|
||||
- `rojo build --output build.rbxl` compiles.
|
||||
|
||||
+8
-6
@@ -14,12 +14,14 @@ Spawn on the neon expressway, drive a car, switch cars, respawn. RX-7 demo-locke
|
||||
to owner + son. Cars persist between sessions. CI green on a self-hosted runner.
|
||||
|
||||
## M1 — Make driving feel GOOD (the real work, do this next)
|
||||
- [ ] Import the **real RX-7 mesh**; swap `CarManager.buildCarModel` only.
|
||||
See `docs/MESH_SWAP.md` for the exact steps.
|
||||
- [ ] Adopt a proven **constraint chassis** (A-Chassis or a modern equivalent)
|
||||
for real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll
|
||||
physics.
|
||||
- [ ] Tune acceleration curve, grip, and the chase camera.
|
||||
- [x] Adopt a **constraint chassis** for real suspension/grip instead of the raw
|
||||
VehicleSeat: springs + prismatic suspension, servo steering (speed-sensitive
|
||||
lock), motor-driven wheels, engine braking, ballast anti-roll. Shared tuning
|
||||
in `src/shared/Chassis.luau`; client-side `ChassisController` drives it
|
||||
(driver owns network physics). **Needs a Studio playtest to tune feel.**
|
||||
- [ ] Import the **real RX-7 mesh**; weld the shell onto the chassis `Body`.
|
||||
See `docs/MESH_SWAP.md` for the exact steps (needs Studio for asset upload).
|
||||
- [ ] Tune acceleration curve, grip, and the chase camera (after first playtest).
|
||||
- [ ] Sound: engine loop, tire screech, boost.
|
||||
**Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else
|
||||
until this is true.
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
+218
-47
@@ -1,14 +1,23 @@
|
||||
--!strict
|
||||
--[[
|
||||
CarManager.luau (SERVER)
|
||||
Spawns the player's equipped car as a simple, PROVEN drivable vehicle.
|
||||
Spawns the player's equipped car as a CONSTRAINT CHASSIS vehicle.
|
||||
|
||||
Design choice (per "don't reinvent the wheel"): instead of a hand-rolled
|
||||
physics chassis, the MVP car is a single driving Part + a VehicleSeat.
|
||||
Roblox's VehicleSeat drives the assembly via its built-in Throttle/Steer —
|
||||
the most reliable, least-buggy way to get a car moving on day one. When you
|
||||
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
|
||||
you swap out `buildCarModel` and keep everything else.
|
||||
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")
|
||||
@@ -17,68 +26,226 @@ 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)
|
||||
|
||||
type Car = Cars.Car
|
||||
type Tuning = Chassis.Tuning
|
||||
|
||||
local CarManager = {}
|
||||
|
||||
local activeCars: { [Player]: Model } = {}
|
||||
local carsFolder: Folder
|
||||
|
||||
-- Convert a car's 0-100 stats into VehicleSeat tuning ------------------------
|
||||
local function computeTuning(car: Car)
|
||||
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
|
||||
local torque = 8000 + car.Stats.Acceleration * 220
|
||||
local turnSpeed = 20 + car.Stats.Handling * 0.35
|
||||
return maxSpeed, torque, turnSpeed
|
||||
-- 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
|
||||
|
||||
-- Build the placeholder car model. Returns the Model with a VehicleSeat.
|
||||
local function buildCarModel(car: Car, maxSpeed: number, torque: number, turnSpeed: number): Model
|
||||
-- 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)
|
||||
|
||||
local prismatic = Instance.new("PrismaticConstraint")
|
||||
prismatic.Name = "SuspSlide_" .. cornerName
|
||||
prismatic.Attachment0 = bodyAtt
|
||||
prismatic.Attachment1 = hubAtt
|
||||
prismatic.LimitsEnabled = true
|
||||
prismatic.LowerLimit = -Chassis.SUSPENSION_TRAVEL / 2
|
||||
prismatic.UpperLimit = Chassis.SUSPENSION_TRAVEL / 2
|
||||
prismatic.Parent = body
|
||||
|
||||
local spring = Instance.new("SpringConstraint")
|
||||
spring.Name = "SuspSpring_" .. cornerName
|
||||
spring.Attachment0 = bodyAtt
|
||||
spring.Attachment1 = hubAtt
|
||||
spring.FreeLength = 0.2
|
||||
spring.Stiffness = tuning.SpringStiffness
|
||||
spring.Damping = tuning.SpringDamping
|
||||
spring.LimitsEnabled = false
|
||||
spring.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
|
||||
|
||||
-- Body (the driving part) --------------------------------------------------
|
||||
local body = Instance.new("Part")
|
||||
body.Name = "Body"
|
||||
body.Size = Vector3.new(6, 2, 12)
|
||||
body.Color = Color3.new(car.Color[1], car.Color[2], car.Color[3])
|
||||
body.Material = Enum.Material.SmoothPlastic
|
||||
body.CanCollide = true
|
||||
body.Parent = model
|
||||
local origin = CFrame.new() -- built at origin; PivotTo() places it later
|
||||
|
||||
-- A cabin block so it reads as a car, welded to the body -------------------
|
||||
local cabin = Instance.new("Part")
|
||||
cabin.Name = "Cabin"
|
||||
cabin.Size = Vector3.new(5, 1.6, 5)
|
||||
cabin.Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7)
|
||||
cabin.Material = Enum.Material.Glass
|
||||
cabin.CanCollide = false
|
||||
cabin.Parent = model
|
||||
-- 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
|
||||
|
||||
-- Cabin (visual only) -------------------------------------------------------
|
||||
local cabin = makePart({
|
||||
Name = "Cabin",
|
||||
Size = Vector3.new(5, 1.6, 5),
|
||||
CFrame = origin * CFrame.new(0, 1.5, 0.5),
|
||||
Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7),
|
||||
Material = Enum.Material.Glass,
|
||||
CanCollide = false,
|
||||
Parent = model,
|
||||
})
|
||||
local cabinWeld = Instance.new("WeldConstraint")
|
||||
cabinWeld.Part0 = body
|
||||
cabinWeld.Part1 = cabin
|
||||
cabinWeld.Parent = body
|
||||
cabin.CFrame = body.CFrame * CFrame.new(0, 1.8, 0.5)
|
||||
|
||||
-- VehicleSeat: Roblox's built-in, reliable driver ------------------------
|
||||
-- Seat: input device only (Throttle/Steer). It drives nothing directly. -----
|
||||
local seat = Instance.new("VehicleSeat")
|
||||
seat.Name = "DriveSeat"
|
||||
seat.Name = Chassis.SEAT_NAME
|
||||
seat.Size = Vector3.new(4, 0.5, 3)
|
||||
seat.MaxSpeed = maxSpeed
|
||||
seat.Torque = torque
|
||||
seat.TurnSpeed = turnSpeed
|
||||
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.CFrame = origin * CFrame.new(0, 1.2, -0.5)
|
||||
seat.Parent = model
|
||||
local seatWeld = Instance.new("WeldConstraint")
|
||||
seatWeld.Part0 = body
|
||||
seatWeld.Part1 = seat
|
||||
seatWeld.Parent = body
|
||||
seat.CFrame = body.CFrame * CFrame.new(0, 1.6, -1)
|
||||
|
||||
-- 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
|
||||
|
||||
model.PrimaryPart = body
|
||||
return model
|
||||
@@ -123,22 +290,26 @@ function CarManager.spawn(player: Player, carId: string?)
|
||||
|
||||
CarManager.despawn(player)
|
||||
|
||||
local maxSpeed, torque, turnSpeed = computeTuning(car)
|
||||
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
|
||||
local tuning = Chassis.tuningFor(car)
|
||||
local model = buildCarModel(car, tuning)
|
||||
model:PivotTo(spawnCFrame())
|
||||
model.Parent = carsFolder
|
||||
model:SetAttribute("OwnerUserId", player.UserId)
|
||||
model:SetAttribute("CarId", id)
|
||||
|
||||
-- Sit the player down. Give the assembly network ownership to the driver so
|
||||
-- physics are smooth on their client (standard Roblox vehicle practice).
|
||||
-- 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("DriveSeat") :: VehicleSeat
|
||||
local body = model.PrimaryPart
|
||||
if body then
|
||||
body:SetNetworkOwner(player)
|
||||
end
|
||||
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat
|
||||
if seat then
|
||||
task.defer(function()
|
||||
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
--!strict
|
||||
--[[
|
||||
Chassis.luau (SHARED)
|
||||
Single source of truth for the constraint chassis: geometry constants,
|
||||
part/constraint naming, and per-car tuning derived from the 0-100 stats.
|
||||
|
||||
Used by BOTH sides:
|
||||
- Server (CarManager.buildCarModel) builds the physical chassis from it.
|
||||
- Client (ChassisController) drives the motors/servos from it.
|
||||
|
||||
Sign conventions (car forward = -Z, Roblox standard):
|
||||
- DRIVE_SIGN: wheel motor AngularVelocity sign for forward throttle.
|
||||
- STEER_SIGN: steering servo TargetAngle sign for steering right.
|
||||
If a future geometry change makes the car drive/steer backwards, flip the
|
||||
sign here — nowhere else.
|
||||
]]
|
||||
|
||||
local Cars = require(script.Parent.Cars)
|
||||
|
||||
type Car = Cars.Car
|
||||
|
||||
local Chassis = {}
|
||||
|
||||
-- Naming conventions (server creates, client finds) ---------------------------
|
||||
Chassis.SEAT_NAME = "DriveSeat"
|
||||
Chassis.WHEEL_MOTOR_NAME = "WheelMotor" -- HingeConstraint (Motor) spinning a wheel
|
||||
Chassis.STEER_SERVO_NAME = "SteerServo" -- HingeConstraint (Servo) yawing a front knuckle
|
||||
|
||||
-- Sign conventions ------------------------------------------------------------
|
||||
Chassis.DRIVE_SIGN = -1 -- forward (-Z) needs negative angular velocity about +X
|
||||
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
|
||||
|
||||
-- Geometry (studs) ------------------------------------------------------------
|
||||
Chassis.BODY_SIZE = Vector3.new(6, 1.4, 12)
|
||||
Chassis.WHEEL_RADIUS = 1.4
|
||||
Chassis.WHEEL_WIDTH = 1.1
|
||||
Chassis.TRACK_HALF = 3.35 -- lateral distance body centre -> wheel centre
|
||||
Chassis.WHEELBASE_HALF = 4.2 -- longitudinal distance body centre -> axle
|
||||
Chassis.SUSPENSION_DROP = 1.6 -- rest distance from body centre down to wheel centre
|
||||
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
|
||||
|
||||
-- Per-car tuning derived from stats -------------------------------------------
|
||||
export type Tuning = {
|
||||
MaxSpeed: number, -- studs/sec target top speed
|
||||
MaxWheelAngularVelocity: number, -- rad/sec at top speed
|
||||
MotorMaxTorque: number, -- accel authority
|
||||
BrakeTorque: number, -- resist-to-zero torque when throttle released
|
||||
MaxSteerAngle: number, -- degrees
|
||||
SteerSpeed: number, -- servo angular speed, rad/sec
|
||||
SpringStiffness: number,
|
||||
SpringDamping: number,
|
||||
WheelFriction: number,
|
||||
}
|
||||
|
||||
function Chassis.tuningFor(car: Car): Tuning
|
||||
local maxSpeed = 70 + car.Stats.Speed * 1.5
|
||||
return {
|
||||
MaxSpeed = maxSpeed,
|
||||
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
|
||||
MotorMaxTorque = 12000 + car.Stats.Acceleration * 500,
|
||||
BrakeTorque = 30000,
|
||||
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
|
||||
SteerSpeed = 6,
|
||||
SpringStiffness = 9000,
|
||||
SpringDamping = 420,
|
||||
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
|
||||
}
|
||||
end
|
||||
|
||||
return Chassis
|
||||
Reference in New Issue
Block a user