diff --git a/docs/MESH_SWAP.md b/docs/MESH_SWAP.md index 1399fed..28889ad 100644 --- a/docs/MESH_SWAP.md +++ b/docs/MESH_SWAP.md @@ -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/` (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 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 01ddda7..1d08a1e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/src/client/ChassisController.luau b/src/client/ChassisController.luau new file mode 100644 index 0000000..ca56081 --- /dev/null +++ b/src/client/ChassisController.luau @@ -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 diff --git a/src/client/init.client.luau b/src/client/init.client.luau index e011f32..8c7e37d 100644 --- a/src/client/init.client.luau +++ b/src/client/init.client.luau @@ -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") diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index 512f6cf..7fad62c 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -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")) diff --git a/src/shared/Chassis.luau b/src/shared/Chassis.luau new file mode 100644 index 0000000..92636c5 --- /dev/null +++ b/src/shared/Chassis.luau @@ -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