diff --git a/src/client/ChassisController.luau b/src/client/ChassisController.luau index 965eeab..0877cf0 100644 --- a/src/client/ChassisController.luau +++ b/src/client/ChassisController.luau @@ -163,11 +163,7 @@ function ChassisController.init() -- destroyed on respawn, so it MUST be nil-checked before it -- is indexed -- that crash is what killed this loop before. local stale = heartbeatConn ~= nil - and ( - drivingSeat ~= seatPart - or seatPart == nil - or not seatPart:IsDescendantOf(workspace) - ) + and (drivingSeat ~= seatPart or seatPart == nil or not seatPart:IsDescendantOf(workspace)) if seated and (not heartbeatConn or stale) then stopDriving() startDriving(seatPart :: VehicleSeat) diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index c4b5352..f94dc45 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -581,8 +581,11 @@ function CarManager.init() end if gone or fell then warn( - ("[CarManager] recovering car for %s (destroyed=%s fell=%s)") - :format(player.Name, tostring(gone), tostring(fell)) + ("[CarManager] recovering car for %s (destroyed=%s fell=%s)"):format( + player.Name, + tostring(gone), + tostring(fell) + ) ) -- CRITICAL: spawn() teleports the car to wherever the -- CHARACTER is. During a void fall the seated character is diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index 511fb33..5213b68 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -29,6 +29,31 @@ local MapBuilder = {} local ROAD_WIDTH = Circuit.ROAD_WIDTH local WALL_HEIGHT = 26 + +-- Divided-expressway layout (real Wangan/C1 geometry) ------------------------- +-- The deck is ONE slab; the median is what splits it into two carriageways +-- running in opposite directions. Outer edges get a visible concrete barrier +-- (you can see it, like real life) backed by an invisible taller wall so a car +-- at 300 km/h can't tunnel through it in a single physics step. +-- Dimensions and palette verified against real photographs of Daikoku JCT / +-- Daikoku Futo PA (Wikimedia Commons aerial + ground refs) and OSM tagging of +-- the junction (way data: lanes=2, oneway=yes per carriageway, layers 1-4). +-- Corrections made after looking at the real thing rather than guessing: +-- * Shutoko structures are PALE CONCRETE / off-white, not dark grey. +-- * The median is a NARROW raised kerb, not a wide chunky wall. +-- * The edge is a low white parapet TOPPED BY A DARK METAL RAILING -- that +-- silhouette is the single most recognisable feature of the road. +-- * Asphalt reads mid-grey in daylight, not near-black. +local MEDIAN_WIDTH = 6 +local MEDIAN_HEIGHT = 3.2 +local BARRIER_H = 5 -- white concrete parapet height +local BARRIER_T = 2.2 -- parapet thickness +local RAIL_H = 3 -- dark metal railing sitting on the parapet + +local C_ASPHALT = Color3.fromRGB(78, 78, 82) +local C_CONCRETE = Color3.fromRGB(214, 212, 205) -- Shutoko pale concrete +local C_PARAPET = Color3.fromRGB(226, 224, 218) +local C_RAIL = Color3.fromRGB(96, 100, 106) local UP = Vector3.new(0, 1, 0) local NEON_PINK = Color3.fromRGB(255, 40, 120) @@ -76,6 +101,49 @@ local function makeSign(text: string, cf: CFrame, parent: Instance) label.Parent = gui end +-- PA junction planning ----------------------------------------------------------- +-- Daikoku PA sits inside a loop of ramp road, and is reachable from BOTH +-- directions of travel. That means the median has to OPEN at the junctions, +-- otherwise the divider we just added would wall the PA off from the far +-- carriageway. buildRoad and buildPA must agree on exactly where those +-- openings are, so the plan is computed once, here, and shared. +local PAPlan = nil :: any + +local function planPA() + if PAPlan then + return PAPlan + end + local samples = Circuit.samples + local c = Circuit.PA_CENTER + -- Nearest non-tunnel sample to the PA = where the ramps meet the mainline. + local bestIdx, bestD = 1, math.huge + for i, s in ipairs(samples) do + local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude + if not s.tunnel and d < bestD then + bestIdx, bestD = i, d + end + end + -- Entry ramp leaves the mainline before the PA, exit ramp rejoins after it. + local n = #samples + local SPAN = 6 -- samples of median opening around each junction + local entryIdx = ((bestIdx - 14 - 1) % n) + 1 + local exitIdx = ((bestIdx + 14 - 1) % n) + 1 + local open = {} + for _, centreIdx in ipairs({ entryIdx, exitIdx }) do + for k = -SPAN, SPAN do + open[((centreIdx + k - 1) % n) + 1] = true + end + end + PAPlan = { + nearIdx = bestIdx, + entryIdx = entryIdx, + exitIdx = exitIdx, + medianOpen = open, + ringRadius = Circuit.PA_RADIUS + 130, + } + return PAPlan +end + -- The expressway itself --------------------------------------------------------- local function buildRoad(map: Folder) local road = Instance.new("Folder") @@ -87,6 +155,7 @@ local function buildRoad(map: Folder) local samples = Circuit.samples local n = #samples + local plan = planPA() for i = 1, n do local a = samples[i] @@ -107,26 +176,59 @@ local function buildRoad(map: Folder) Name = "Road_" .. i, Size = Vector3.new(ROAD_WIDTH, 2, len), CFrame = cf, - Color = Color3.fromRGB(28, 28, 34), + Color = C_ASPHALT, Material = Enum.Material.Asphalt, }) deck.Parent = road - -- lane markings: C1 is 2 lanes each way; centre line + dashes - local centre = part({ - Name = "Lane_" .. i, - Size = Vector3.new(1.2, 2.1, len), - CFrame = cf * CFrame.new(0, 0.1, 0), - Color = Color3.fromRGB(235, 205, 70), - Material = Enum.Material.Neon, - }) - centre.Parent = road - if i % 2 == 0 then - for _, s in ipairs({ -1, 1 }) do + -- CENTRAL DIVIDER --------------------------------------------------- + -- Real Wangan has a solid median, not a painted line: two lanes each + -- way, physically separated. This is collidable, so a head-on into + -- oncoming traffic isn't possible -- you hit the divider instead. + local HALF = ROAD_WIDTH / 2 + local MED_HALF = MEDIAN_WIDTH / 2 + -- ...except at the PA junctions, where it opens so both directions of + -- travel can cross into the parking area (see planPA). + if not plan.medianOpen[i] then + local median = part({ + Name = "Median_" .. i, + Size = Vector3.new(MEDIAN_WIDTH, MEDIAN_HEIGHT, len), + CFrame = cf * CFrame.new(0, 1 + MEDIAN_HEIGHT / 2, 0), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + median.Parent = struct + -- Dark steel anti-glare fence along the median -- on the real road + -- this strip of vertical blades is what separates the directions. + local fence = part({ + Name = "MedianFence", + Size = Vector3.new(0.8, 2.6, len), + CFrame = cf * CFrame.new(0, 1 + MEDIAN_HEIGHT + 1.3, 0), + Color = C_RAIL, + Material = Enum.Material.Metal, + }) + fence.Parent = struct + end + + -- LANE MARKINGS: 2 lanes per carriageway ---------------------------- + -- Each carriageway spans MED_HALF..HALF from the centreline. One dashed + -- line down its middle splits it into the two lanes; a solid white edge + -- line runs just inside the outer barrier. + local cwMid = (MED_HALF + HALF) / 2 + for _, s in ipairs({ -1, 1 }) do + local edge = part({ + Name = "EdgeLine", + Size = Vector3.new(0.9, 2.1, len), + CFrame = cf * CFrame.new(s * (HALF - 1.6), 0.1, 0), + Color = Color3.fromRGB(228, 228, 233), + Material = Enum.Material.SmoothPlastic, + }) + edge.Parent = road + if i % 2 == 0 then local dash = part({ Name = "Dash", Size = Vector3.new(0.8, 2.1, len * 0.55), - CFrame = cf * CFrame.new(s * ROAD_WIDTH / 4, 0.1, 0), + CFrame = cf * CFrame.new(s * cwMid, 0.1, 0), Color = Color3.fromRGB(220, 220, 225), Material = Enum.Material.SmoothPlastic, }) @@ -134,19 +236,41 @@ local function buildRoad(map: Folder) end end - -- barriers + invisible kill walls + -- OUTER EDGE BARRIERS ----------------------------------------------- + -- Visible concrete Jersey barrier sitting ON the deck (this is the part + -- you actually see), then an invisible taller wall just outside it as a + -- backstop against tunnelling at high speed. + -- At a PA junction the barrier AND the kill wall must open on the side + -- the ramp leaves through, or the ramp would dead-end into a wall. + local junction = plan.medianOpen[i] for _, side in ipairs({ -1, 1 }) do - local off = right * (side * (ROAD_WIDTH / 2)) + if junction then + continue + end + local boff = right * (side * (HALF - BARRIER_T / 2)) + local bcf = CFrame.lookAt(mid + boff, mid + boff + dir, UP) + -- White concrete parapet... + local barrier = part({ + Name = "Barrier", + Size = Vector3.new(BARRIER_T, BARRIER_H, len), + CFrame = bcf * CFrame.new(0, 1 + BARRIER_H / 2, 0), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + barrier.Parent = struct + -- ...topped by the dark metal railing. Every photo of the Shutoko + -- shows this two-tone parapet+railing edge; without the railing the + -- road reads as a generic slab instead of a Japanese expressway. local rail = part({ - Name = "Guardrail", - Size = Vector3.new(1.5, 4, len), - CFrame = CFrame.lookAt(mid + off, mid + off + dir, UP) * CFrame.new(0, 2, 0), - Color = Color3.fromRGB(180, 180, 190), + Name = "EdgeRail", + Size = Vector3.new(0.6, RAIL_H, len), + CFrame = bcf * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0), + Color = C_RAIL, Material = Enum.Material.Metal, }) rail.Parent = struct - local woff = right * (side * (ROAD_WIDTH / 2 + 2)) + local woff = right * (side * (HALF + 1)) local wall = part({ Name = "KillWall", Size = Vector3.new(2, WALL_HEIGHT, len + 6), @@ -196,7 +320,7 @@ local function buildRoad(map: Folder) Name = "Pillar", Size = Vector3.new(9, a.pos.Y, 9), CFrame = CFrame.new(mid.X, a.pos.Y / 2 - 1, mid.Z), - Color = Color3.fromRGB(60, 60, 68), + Color = C_CONCRETE, Material = Enum.Material.Concrete, }) pil.Parent = struct @@ -256,12 +380,17 @@ local function buildPA(map: Folder) local c = Circuit.PA_CENTER local R = Circuit.PA_RADIUS + -- Real Daikoku Futo PA: the parking apron is a huge flat lot, and the loop + -- ramps sweep AROUND and OVER it on pillars (see the aerial reference) -- + -- the parking is inside the ring, at deck level, with the ring elevated + -- above it. The apron reaches out under the ring so there is no void gap. + local APRON = Circuit.PA_RADIUS + 130 + 24 -- ringRadius + half ring width local slab = part({ Name = "PAFloor", - Size = Vector3.new(R * 2, 2, R * 2), + Size = Vector3.new(APRON * 2, 2, APRON * 2), CFrame = CFrame.new(c), - Color = Color3.fromRGB(42, 42, 48), - Material = Enum.Material.Concrete, + Color = C_ASPHALT, + Material = Enum.Material.Asphalt, }) slab.Parent = pa @@ -271,7 +400,7 @@ local function buildPA(map: Folder) Name = "Bay", Size = Vector3.new(1, 2.2, 90), CFrame = CFrame.new(c + Vector3.new(i * 34, 0.1, 0)), - Color = Color3.fromRGB(225, 225, 230), + Color = Color3.fromRGB(235, 235, 240), Material = Enum.Material.SmoothPlastic, }) bay.Parent = pa @@ -304,83 +433,107 @@ local function buildPA(map: Folder) end end - -- link ramp from the PA back onto the expressway + -- RING ROAD + ON/OFF RAMPS ------------------------------------------------ + -- At the real Daikoku Futo PA the slip road wraps right around the parking + -- area in a full circle, with the on-ramp and off-ramp feeding that loop. + -- Build the ring as a fan of short arc segments, then two ramps joining it + -- to the expressway at the junctions the median opens for. local samples = Circuit.samples - local bestIdx, bestD = 1, math.huge - for i, s in ipairs(samples) do - local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude - if not s.tunnel and d < bestD then - bestIdx, bestD = i, d - end + local plan = planPA() + local RING_R = plan.ringRadius + local RING_W = 44 + local ARC_N = 72 + + local function deck(name, size, cframe, colour) + local d = part({ + Name = name, + Size = size, + CFrame = cframe, + Color = colour or Color3.fromRGB(38, 38, 44), + Material = Enum.Material.Asphalt, + }) + d.Parent = pa + return d end - local road = samples[bestIdx].pos - local ramp = part({ - Name = "PARamp", - Size = Vector3.new(46, 2, (road - c).Magnitude), - CFrame = CFrame.lookAt((road + c) / 2, road, UP), - Color = Color3.fromRGB(38, 38, 44), - Material = Enum.Material.Asphalt, - }) - ramp.Parent = pa - -- PERIMETER BARRIERS: the PA floor is a 520x520 slab floating ~46 studs up - -- with nothing at its edges. Endurance testing drove straight off the side - -- and fell past FallenPartsDestroyHeight (-500), destroying the car and - -- stranding the player seatless until the death/respawn cycle caught it. - -- Wall every edge, leaving a gap on the side the ramp exits through so the - -- route to the expressway stays open. - do - local rampDir = Vector3.new(road.X - c.X, 0, road.Z - c.Z) - -- Which edge does the ramp leave from? Whichever axis dominates. - local rampOnX = math.abs(rampDir.X) > math.abs(rampDir.Z) - local rampSign = rampOnX and math.sign(rampDir.X) or math.sign(rampDir.Z) - local GAP = 70 -- half-width of the opening (ramp is 46 wide) - local H, T = 14, 4 + -- The ring itself + local ringY = c.Y + for i = 0, ARC_N - 1 do + local a0 = (i / ARC_N) * math.pi * 2 + local a1 = ((i + 1) / ARC_N) * math.pi * 2 + local p0 = Vector3.new(c.X + math.cos(a0) * RING_R, ringY, c.Z + math.sin(a0) * RING_R) + local p1 = Vector3.new(c.X + math.cos(a1) * RING_R, ringY, c.Z + math.sin(a1) * RING_R) + local segMid = (p0 + p1) / 2 + local segLen = (p1 - p0).Magnitude + 1.5 + local segCF = CFrame.lookAt(segMid, p1, UP) + deck("PARing", Vector3.new(RING_W, 2, segLen), segCF) - local function wall(name, size, pos) - local w = part({ - Name = name, - Size = size, - CFrame = CFrame.new(pos), - Color = Color3.fromRGB(96, 96, 104), + -- Both edges of the ring get the same white parapet + dark railing as + -- the mainline, so the loop reads as Shutoko structure from below. + local fwd = (p1 - p0).Unit + local outN = (segMid - Vector3.new(c.X, ringY, c.Z)).Unit + for _, sgn in ipairs({ 1, -1 }) do + local ePos = segMid + outN * (sgn * RING_W / 2) + local eCF = CFrame.lookAt(ePos, ePos + fwd, UP) + local lip = part({ + Name = "PARingWall", + Size = Vector3.new(2.2, BARRIER_H, segLen + 1), + CFrame = eCF * CFrame.new(0, 1 + BARRIER_H / 2, 0), + Color = C_PARAPET, Material = Enum.Material.Concrete, }) - w.Parent = pa + lip.Parent = pa + local lr = part({ + Name = "PARingRail", + Size = Vector3.new(0.6, RAIL_H, segLen + 1), + CFrame = eCF * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0), + Color = C_RAIL, + Material = Enum.Material.Metal, + }) + lr.Parent = pa end - -- Four edges. On the ramp's edge, build two stubs flanking the opening. - local edges = { - { onX = true, sign = 1 }, - { onX = true, sign = -1 }, - { onX = false, sign = 1 }, - { onX = false, sign = -1 }, - } - for _, e in ipairs(edges) do - local isRampEdge = (e.onX == rampOnX) and (e.sign == rampSign) - -- Offset along the edge normal; span the full slab on the other axis. - local normal = e.onX and Vector3.new(e.sign * R, 0, 0) or Vector3.new(0, 0, e.sign * R) - local base = c + normal + Vector3.new(0, H / 2, 0) - local fullSize = e.onX and Vector3.new(T, H, R * 2) or Vector3.new(R * 2, H, T) - if not isRampEdge then - wall("PAWall", fullSize, base) - else - -- two stubs, each running from the corner in to the gap edge - local stubLen = R - GAP - if stubLen > 4 then - local half = stubLen / 2 - local off = GAP + half - if e.onX then - wall("PAWall", Vector3.new(T, H, stubLen), base + Vector3.new(0, 0, off)) - wall("PAWall", Vector3.new(T, H, stubLen), base + Vector3.new(0, 0, -off)) - else - wall("PAWall", Vector3.new(stubLen, H, T), base + Vector3.new(off, 0, 0)) - wall("PAWall", Vector3.new(stubLen, H, T), base + Vector3.new(-off, 0, 0)) - end - end - end + -- Pillars holding the loop up over the parking lot, like the aerial. + if i % 6 == 0 and ringY > 12 then + local pil = part({ + Name = "PARingPillar", + Size = Vector3.new(7, ringY, 7), + CFrame = CFrame.new(segMid.X, ringY / 2 - 1, segMid.Z), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + }) + pil.Parent = pa end end + -- Two ramps: mainline -> ring (entry) and ring -> mainline (exit). + for _, j in ipairs({ + { idx = plan.entryIdx, name = "PAOnRamp" }, + { idx = plan.exitIdx, name = "PAOffRamp" }, + }) do + local road = samples[j.idx].pos + local toC = Vector3.new(c.X - road.X, 0, c.Z - road.Z) + if toC.Magnitude < 1 then + continue + end + -- Meet the ring at its nearest point to this junction, not the centre, + -- so the ramp tees into the loop instead of crossing over it. + local ringPt = Vector3.new(c.X, ringY, c.Z) - toC.Unit * RING_R + local span = (ringPt - road).Magnitude + if span < 4 then + continue + end + deck(j.name, Vector3.new(RING_W, 2, span), CFrame.lookAt((ringPt + road) / 2, road, UP)) + end + + -- The PA apron used to be a bare 520x520 slab floating ~46 studs up with + -- nothing at its edges: endurance testing drove straight off the side and + -- fell past FallenPartsDestroyHeight (-500), destroying the car and + -- stranding the player seatless. That is now solved structurally -- the + -- apron below extends all the way out to the ring road, and the ring's + -- outer lip walls the whole thing -- so there is no exposed edge to fall + -- off and no need for the old four-sided perimeter box. + local pad = part({ Name = "SpawnPad", Size = Vector3.new(120, 2.4, 120), diff --git a/src/shared/Chassis.luau b/src/shared/Chassis.luau index b531839..0697d64 100644 --- a/src/shared/Chassis.luau +++ b/src/shared/Chassis.luau @@ -31,13 +31,18 @@ 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 +-- Sized to REAL imported car meshes, not the other way around. The donor models +-- in ServerStorage/CarLibrary average 17.4 studs long / ~8.5 wide / ~5 tall, +-- which is correct for a Roblox character (4.7 studs tall) to sit in. The old +-- 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 +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 -- Per-car tuning derived from stats ------------------------------------------- export type Tuning = { @@ -71,6 +76,14 @@ export type Tuning = { -- critical damping. The old pair worked out to zeta = 0.31 (floaty, -- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within -- that band by Handling so a grippy car is also a composed one. +-- +-- !! CURRENTLY INERT !! CarManager.buildCorner welds the hub rigidly to the +-- body (see the comment there: a light hub on a stiff spring made the solver +-- diverge and fling the wheels away). There is no SpringConstraint in the rig +-- today, so SpringStiffness/SpringDamping below are computed but never +-- consumed -- the ride is rigid, not damped. These stay here because the +-- numbers are now correct and are what a re-introduced spring should use; +-- do NOT read a smooth ride-height trace as evidence they are working. local GRAVITY = 196.2 -- studs/s^2 (Roblox default) local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel