diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index f94dc45..a89e403 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -51,7 +51,10 @@ local SETTLE_TIME = 1.0 -- How far to sink an imported mesh shell into the chassis so it doesn't look -- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false). -local SHELL_DROP = 0.6 +-- Sink the shell slightly so the tyres aren't fully swallowed by the bodywork. +-- Every stud of drop is a stud of cabin height lost, and hyper_x (lowest roof +-- in the library at 4.64) has none to spare, so keep this small. +local SHELL_DROP = 0.25 -- Collision groups ------------------------------------------------------------ -- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body @@ -63,6 +66,13 @@ local SHELL_DROP = 0.6 -- group that ignores the chassis is the fix; the geometry is intentional. local WHEEL_GROUP = "CarWheel" local CHASSIS_GROUP = "CarChassis" +-- Seating the driver on the cabin FLOOR (see Chassis.SEAT_ABOVE_GROUND) tucks +-- the avatar down inside the body slab, so its legs/torso now genuinely +-- interpenetrate the chassis. Overlapping colliders is exactly what launches a +-- character across the map, so the driver gets its own group that ignores the +-- car. The driver still collides with the world, so it can't clip through +-- terrain when ejected. +local DRIVER_GROUP = "CarDriver" local groupsReady = false local function ensureCollisionGroups() @@ -73,6 +83,7 @@ local function ensureCollisionGroups() local ok = pcall(function() PhysicsService:RegisterCollisionGroup(WHEEL_GROUP) PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP) + PhysicsService:RegisterCollisionGroup(DRIVER_GROUP) end) if not ok then -- Already registered (Play-solo re-entry) -- harmless. @@ -80,6 +91,23 @@ local function ensureCollisionGroups() PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false) PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true) PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false) + PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, CHASSIS_GROUP, false) + PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, WHEEL_GROUP, false) +end + +--- Put every part of a character into the driver collision group (or back into +--- Default when they get out), so a low-seated avatar can't fight the chassis. +local function setCharacterGroup(character: Model?, group: string) + if not character then + return + end + for _, d in ipairs(character:GetDescendants()) do + if d:IsA("BasePart") then + pcall(function() + d.CollisionGroup = group + end) + end + end end -- Helpers --------------------------------------------------------------------- @@ -260,10 +288,14 @@ local function buildCarModel(car: Car, tuning: Tuning): Model 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 pan on the CABIN FLOOR, measured up from the ground plane the wheels + -- rest on -- NOT up from the body slab. The slab sits at the car's vertical + -- middle, so the old "slab top + 0.3" placement put the driver's hips at the + -- car's waistline and its head through the roof. Sitting the avatar this low + -- means its torso now overlaps the slab, which is why the driver goes in its + -- own collision group (see DRIVER_GROUP). + seat.CFrame = origin + * CFrame.new(0, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + seat.Size.Y / 2, 0.2) seat.Parent = model local seatWeld = Instance.new("WeldConstraint") seatWeld.Part0 = body @@ -288,10 +320,128 @@ local function buildCarModel(car: Car, tuning: Tuning): Model if shellTemplate then local shell = shellTemplate:Clone() shell.Name = "Shell" - -- Seat the shell on the chassis: drop it so the mesh's underside sits at - -- roughly the body plane rather than floating at the body's centreline. - local ext = shell:GetExtentsSize() - shell:PivotTo(body.CFrame * CFrame.new(0, ext.Y / 2 - Chassis.BODY_SIZE.Y / 2 - SHELL_DROP, 0)) + -- Measure the shell from its PARTS, not from Model:GetExtentsSize() / + -- Model:GetBoundingBox(). Both of those return an axis-aligned box that + -- is inflated by rotated parts: silv_s15 reports H=12.32 when its real + -- geometry is only 4.71 tall, which made it hover ~4 studs in the air. + -- Accumulating each part's own rotated half-extents gives the true box. + local function trueAABB(model) + local lo = Vector3.new(math.huge, math.huge, math.huge) + local hi = Vector3.new(-math.huge, -math.huge, -math.huge) + local found = false + for _, p in ipairs(model:GetDescendants()) do + if p:IsA("BasePart") then + found = true + local cf, s = p.CFrame, p.Size + local hx = 0.5 * (math.abs(cf.RightVector.X * s.X) + + math.abs(cf.UpVector.X * s.Y) + math.abs(cf.LookVector.X * s.Z)) + local hy = 0.5 * (math.abs(cf.RightVector.Y * s.X) + + math.abs(cf.UpVector.Y * s.Y) + math.abs(cf.LookVector.Y * s.Z)) + local hz = 0.5 * (math.abs(cf.RightVector.Z * s.X) + + math.abs(cf.UpVector.Z * s.Y) + math.abs(cf.LookVector.Z * s.Z)) + local c = p.Position + lo = Vector3.new(math.min(lo.X, c.X - hx), math.min(lo.Y, c.Y - hy), math.min(lo.Z, c.Z - hz)) + hi = Vector3.new(math.max(hi.X, c.X + hx), math.max(hi.Y, c.Y + hy), math.max(hi.Z, c.Z + hz)) + end + end + if not found then + return nil, nil, nil + end + return lo, hi, hi - lo + end + + -- 1. NORMALISE SIZE. Library meshes range 15.6 -> 19.0 studs long at + -- native scale, so cars looked inconsistent and the short ones had no + -- cabin room for the avatar. Uniform Model:ScaleTo keeps each mesh's + -- own proportions while making every car the same length. + -- The long axis is NOT always Z: gtr_r34 and hyper_x are modelled + -- rotated 90 degrees (length on X), so scaling by .Z blew them up to + -- ~2x. Use whichever horizontal axis is actually longer. + local _, _, rawSize = trueAABB(shell) + if rawSize then + local rawLen = math.max(rawSize.X, rawSize.Z) + if rawLen > 0.01 then + shell:ScaleTo(Chassis.SHELL_LENGTH / rawLen) + end + end + -- 2. SIT IT ON THE GROUND. Align the true box's BOTTOM to the ground + -- plane the wheels rest on, then sink by SHELL_DROP so tyres aren't + -- swallowed. Measured against the pivot, since PivotTo moves the pivot. + local lo2 = trueAABB(shell) + local bottomOffset = 0 + if lo2 then + bottomOffset = lo2.Y - shell:GetPivot().Position.Y + end + shell:PivotTo( + body.CFrame * CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0) + ) + -- 3. DROP THE SEAT TO FIT THIS SHELL'S ROOF. Cabin height varies by more + -- than a stud across the library (hyper_x roof sits 0.72 above the + -- body plane, gtr_r34 1.80). A single fixed seat height that looks + -- right in the tallest car pushes the driver's head through the + -- roof of the lowest one, so solve for the seat from the measured + -- roofline instead: seat = roof - head height - clearance. + local seatPart = model:FindFirstChild(Chassis.SEAT_NAME) + if seatPart and rawSize then + -- The roofline is NOT the top of the shell's bounding box. Every + -- library mesh carries junk above the cabin -- gtr_r34 has a stray + -- part literally named "Brick" 0.74 studs above its roof, rx7_fc + -- 0.51 -- so measuring to the AABB top invents headroom that isn't + -- there and the head pokes through the actual roof. Take the top of + -- the largest part by volume instead: that is the main body mesh. + local lo3, hi3 = trueAABB(shell) + local roofY = hi3 and hi3.Y or nil + local bestVol = -1 + for _, p in ipairs(shell:GetDescendants()) do + if p:IsA("BasePart") then + local vol = p.Size.X * p.Size.Y * p.Size.Z + if vol > bestVol then + bestVol = vol + local cf, sz = p.CFrame, p.Size + roofY = p.Position.Y + 0.5 * (math.abs(cf.RightVector.Y * sz.X) + + math.abs(cf.UpVector.Y * sz.Y) + + math.abs(cf.LookVector.Y * sz.Z)) + end + end + end + if roofY and lo3 then + local roofLocal = roofY - body.Position.Y + -- HEAD_ABOVE_SEAT was measured from the seat part's CENTRE, but + -- seatLocal below is the pan height (the part is then placed at + -- seatLocal + Size.Y/2). Subtract the half-thickness here or every + -- car quietly loses that much of its intended clearance -- which + -- is why hyper_x measured 0.07 instead of the requested 0.30. + local wanted = roofLocal - Chassis.HEAD_ABOVE_SEAT + - Chassis.ROOF_CLEARANCE - seatPart.Size.Y / 2 + -- Allow sinking to the cabin floor: a low roof must win over the + -- nominal seat height, otherwise the clamp silently discards the + -- whole adaptive solve (which is what used to happen). The shell + -- bottom sits SHELL_DROP below the ground plane, so the seat can + -- go slightly under GROUND_OFFSET without breaching the underside + -- -- hyper_x (lowest roof) needs that headroom. + local floor = Chassis.GROUND_OFFSET - SHELL_DROP + 0.1 + local seatLocal = math.clamp( + wanted, + floor, + Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + ) + seatPart.CFrame = body.CFrame + * CFrame.new(0, seatLocal + seatPart.Size.Y / 2, 0.2) + -- A WeldConstraint locks in the relative offset it had when it + -- was created, so moving the seat afterwards would be snapped + -- straight back. Rebuild the weld at the new offset. + for _, w in ipairs(body:GetChildren()) do + if w:IsA("WeldConstraint") + and (w.Part1 == seatPart or w.Part0 == seatPart) then + w:Destroy() + end + end + local seatWeld2 = Instance.new("WeldConstraint") + seatWeld2.Part0 = body + seatWeld2.Part1 = seatPart + seatWeld2.Parent = body + end + end local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true) for _, d in ipairs(shell:GetDescendants()) do if d:IsA("BasePart") then @@ -445,8 +595,22 @@ function CarManager.spawn(player: Player, carId: string?) end AntiCheat.grant(player) + -- Driver ignores the chassis BEFORE sitting: the avatar is seated + -- down inside the body slab, and overlapping colliders at Sit() is + -- what launches characters. + setCharacterGroup(character, DRIVER_GROUP) seat:Sit(humanoid) + -- Restore normal collision when they get out, otherwise the player + -- walks around permanently unable to touch cars. + local seatConn: RBXScriptConnection + seatConn = seat:GetPropertyChangedSignal("Occupant"):Connect(function() + if seat.Occupant == nil then + setCharacterGroup(character, "Default") + seatConn:Disconnect() + end + end) + task.defer(function() task.wait(SETTLE_TIME) -- let Sit() + the solver settle if not primary:IsDescendantOf(Workspace) then diff --git a/src/shared/Chassis.luau b/src/shared/Chassis.luau index 0697d64..2bf9cd3 100644 --- a/src/shared/Chassis.luau +++ b/src/shared/Chassis.luau @@ -37,12 +37,57 @@ Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y -- 12-stud chassis forced those meshes to be scaled DOWN ~26%, which left the -- driver sticking out of the roof. Chassis now matches the meshes at 1:1 scale. Chassis.BODY_SIZE = Vector3.new(6.6, 1.6, 17.0) -Chassis.WHEEL_RADIUS = 2.0 -Chassis.WHEEL_WIDTH = 1.4 -Chassis.TRACK_HALF = 3.7 -- lateral distance body centre -> wheel centre +-- Wheel size is set from the shell length, not picked by eye. A real car's +-- tyre diameter is ~14% of its length; at SHELL_LENGTH 19 that is ~2.7 studs. +-- The old 4.0-stud tyre (21%) is what made every car read as a monster truck, +-- and because GROUND_OFFSET is derived from it the whole body rode 4.2 studs +-- up in the air to clear it. +Chassis.WHEEL_RADIUS = 1.35 +Chassis.WHEEL_WIDTH = 1.1 +Chassis.TRACK_HALF = 3.55 -- lateral distance body centre -> wheel centre Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle -Chassis.SUSPENSION_DROP = 2.2 -- rest distance from body centre down to wheel centre -Chassis.SUSPENSION_TRAVEL = 1.2 -- total prismatic travel allowed +Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre +Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed + +-- Cabin fit -------------------------------------------------------------------- +-- The driver used to stick out of the roof for THREE compounding reasons, all +-- fixed here + in CarManager: +-- +-- 1. SEAT AT ROOF HEIGHT. The Body slab sits at the car's vertical MIDDLE, +-- but the seat was mounted just above it (BODY_SIZE.Y/2 + 0.3). That put +-- the avatar's hips level with the car's waistline and its head a full +-- stud above the roof. A seat belongs on the CABIN FLOOR, which is near +-- the car's BOTTOM -- i.e. measured up from the ground plane, not up from +-- the body slab. SEAT_ABOVE_GROUND does that. +-- 2. SHELLS NEVER SCALED. Imported meshes were parented at native size, which +-- ranges 15.4 -> 19.0 studs long across the library. Cars visibly differed +-- in size and the short ones had no cabin room. SHELL_LENGTH normalises +-- every shell to one length; the mesh's own proportions are preserved +-- because the scale is uniform (Model:ScaleTo). +-- 3. SHELL FLOATED. Placement used ext.Y/2 (a raw extents number that ignores +-- a model's pivot offset -- silv_s15's pivot is 1.45 studs off its own +-- geometry), so shells hovered above the wheels. CarManager now aligns the +-- shell's true bounding box BOTTOM to the ground plane. +-- +-- Ground plane, relative to the Body part's centre: the wheels rest on it, so +-- it is one suspension drop plus one wheel radius below the body centre. +Chassis.GROUND_OFFSET = -(Chassis.SUSPENSION_DROP + Chassis.WHEEL_RADIUS) +-- Height of the seat PAN above that ground plane (cabin floor + cushion). +-- This is the STARTING height; CarManager lowers it per-car after the shell is +-- measured (see HEAD_ABOVE_SEAT / ROOF_CLEARANCE), because shells differ in +-- roof height by over a stud (hyper_x 4.92 vs gtr_r34 6.00). +Chassis.SEAT_ABOVE_GROUND = 0.45 +-- Measured in Play on a default R15 avatar: the head CENTRE sits 3.27 above the +-- seat pan, but the head is 1.20 tall, so the CROWN is 3.87 up. The old 3.27 +-- constant compared a centre against a roofline and so under-measured the +-- avatar by half a head -- the head cleared on paper and poked out on screen. +-- This is deliberately the crown, i.e. the number that must stay under the roof. +Chassis.HEAD_ABOVE_SEAT = 3.90 +-- Target gap between the crown and the shell roofline. Now that HEAD_ABOVE_SEAT +-- is a true crown height this is real headroom, so it can be modest. +Chassis.ROOF_CLEARANCE = 0.30 +-- Every imported shell is uniformly scaled so its bounding box is this long. +Chassis.SHELL_LENGTH = 19.0 -- Per-car tuning derived from stats ------------------------------------------- export type Tuning = {