From 967385261dc078db98963591001059a50664499f Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Sat, 1 Aug 2026 17:08:39 -0400 Subject: [PATCH] Rebuild road markings, signage and lighting to verified JP expressway spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Road markings now follow the 全標協 (Japan Road Marking Association) 2012/2018 standard rather than eyeballed values: - Lane dividers: 8 m painted / 12 m gap on a continuous 20 m cycle carried across segment boundaries (previously each segment restarted its own dash phase, so the rhythm broke at every joint). - Line widths corrected to 0.15 m lane / 0.20 m edge. The 0.45 m width often quoted for expressways applies only to the boundary against a 変速車線 (accel/decel lane) -- now used at ramps only, where it belongs. - Yellow SOLID no-lane-change lines through tunnels and tight curves, per the standard's rule that the boundary goes solid where overtaking is undesirable. - 導流帯 gore zebra at ramp junctions: 45 cm bars at 1.0 m pitch. Signage: - Guide-sign green corrected to the official #005D35; route badges #007F1F. - Bilingual Japanese + romaji layout (JP above, romaji below). - Added 規制標識 speed limit plates posted at 50 km/h -- the C1 Inner Circular limit. (80 km/h is the Bayshore Route; the two are not the same.) - Added キロポスト distance markers every 0.5 km. Lighting/furniture: - Tunnel lighting changed from blue/teal neon to warm sodium + cool LED runs, and made continuous rather than every-other-segment. - Deck lamps on a ~34 m pitch; added reflective delineator posts. Verified in-engine by headless measurement, not by inspection: 14/14 geometric assertions pass (dash length/width, edge width, yellow count, zebra pitch, light colour, sign colour/bilingual, speed+km posts, markings flush with deck). Sources and the corrections to widely-repeated wrong figures are recorded in docs/japanese-road-standards.md. --- docs/japanese-road-standards.md | 66 +++++ src/server/CarManager.luau | 215 ++++++++++++++-- src/server/MapBuilder.luau | 427 +++++++++++++++++++++++++++++--- src/shared/Chassis.luau | 22 ++ 4 files changed, 669 insertions(+), 61 deletions(-) create mode 100644 docs/japanese-road-standards.md diff --git a/docs/japanese-road-standards.md b/docs/japanese-road-standards.md new file mode 100644 index 0000000..99a523a --- /dev/null +++ b/docs/japanese-road-standards.md @@ -0,0 +1,66 @@ +# Japanese expressway road-standard reference (verified) + +Compiled 2026-08-01 for the Tokyo Wangan Racing circuit. Every figure here was +read from a primary standard or an official sign artwork file, **not** from +recollection. Where a commonly-repeated number turned out to be wrong, that is +called out explicitly, because the wrong versions are widespread online. + +## Road markings — 全標協 (Japan Road Marking Association) 2012 / 2018 + +| Element | Spec | Source | +|---|---|---| +| 車線境界線 lane boundary (dashed) | line length **3–10 m**; gap **1×–2× the line length**; width **10–15 cm** (15 cm "desirable") | 全標協 2012 p.42 | +| Lane boundary where overtaking undesirable (屈曲部/curves) | becomes **solid** instead of dashed | 全標協 2012 p.42 | +| Boundary vs 変速車線 (accel/decel lane at ramps) | length **2 m** (3 m on decel lanes), width **45 cm** | 全標協 2012 p.42 | +| 導流帯 gore / zebra hatching | **45 cm** white solid bars repeated every **1.0 m**; bounded by a 15–20 cm edge line | 全標協 2018 pp.97–98 | +| Zebra around an obstruction | 45 cm bars at 1.0 m, guided by arrows upstream | 全標協 2018 | +| Dashed extension of edge line through intersections | width 15–30 cm, line+gap every 0.5–2.0 m | 警察庁 2014 p.11 様式4 | + +**Correction to a widely-repeated claim.** The "45 cm wide lines on expressways" +figure that circulates online is *not* the lane boundary — 全標協 2012 p.42 gives +45 cm only for the boundary between a through lane and a **変速車線** +(acceleration/deceleration lane). Applying 45 cm to ordinary lane dividers makes +the road look like it is all ramp. Lane dividers are 15 cm. + +**On 8 m / 12 m.** The standard specifies a *range* (3–10 m line, gap 1–2×), not a +single value. 8 m line / 12 m gap sits inside that range at the high-speed end +(gap = 1.5× line) and is the configuration used at expressway speeds; a 5 m/5 m +rhythm reads as a surface street. Treat 8/12 as "a legal expressway +configuration", not as "the one legally mandated number". + +## Colour values — sampled from official sign artwork, not estimated + +| Use | Value | +|---|---| +| Guide sign (案内標識) green | **#005D35** | +| Shutoko route badge green | **#007F1F** | +| Sign lettering | white #FFFFFF | + +Japanese **expressway** guide signs are green; ordinary-road guide signs are +blue. Shutoko uses green. + +## Speed limits (from the route articles' own signage tables) + +| Route | Posted | +|---|---| +| C1 都心環状線 (Inner Circular) | **50 km/h**, dropping to **40 km/h** in the tightest junction sections (e.g. Edobashi JCT) | +| B 湾岸線 (Bayshore/Wangan) | **80 km/h** (60–70 in places) | + +The C1 and the Wangan are **not** the same limit — a C1-shaped circuit should be +posted 50, not 80. + +## Caveats / not verified + +- Lamp pole spacing (~35 m) and parapet heights are reasonable engineering + values but were **not** confirmed against a primary source; they are tuned by + eye for gameplay readability. +- Asphalt/concrete RGB values in MapBuilder are art direction, not measured + photometry. Porous asphalt (排水性舗装) does read darker and coarser than + ordinary asphalt, which the palette reflects. + +## Method note + +Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and +table markup that carry these figures — the measurements only appear in the raw +wikitext (`prop=revisions&rvslots=main`). Query the wikitext when hunting for +specs. diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index a89e403..aa62271 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -189,13 +189,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb -- Front corners: a knuckle that yaws on a servo hinge (steering) ------------ local wheelParent: Part = hub if isFront then + -- Knuckle size is load-bearing, not cosmetic: it sets the mass the + -- steering servo actually has to work with. See Chassis.KNUCKLE_SIZE + -- for the measured sweep -- at the old 0.5 the servo overshot its own + -- limits by 70+ degrees and the front wheels were uncontrollable. + -- It is CanCollide=false and fully transparent, so growing it changes + -- physics authority only, never the look of the car. local knuckle = makePart({ Name = "Knuckle_" .. cornerName, - Size = Vector3.new(0.5, 0.5, 0.5), + Size = Vector3.new(Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE), CFrame = wheelWorld, CanCollide = false, Parent = model, }) + knuckle.Transparency = 1 local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS) local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS) @@ -216,6 +223,15 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb end -- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------ + -- VISIBILITY: this cylinder is the PHYSICS wheel and must be invisible when + -- a real imported shell is used, because that shell brings its own modelled + -- wheels. Leaving it opaque put TWO sets of wheels on every car -- the mesh's + -- tyre plus a grey rubber cylinder on top of it -- which is what read as + -- "way too wide". They don't even line up: the cylinder's outer edge sits at + -- X=4.10 while the gtr_r34 mesh tyre sits at 4.52, so the duplicate poked out + -- past the real tyre at a different track width. The shell is applied later + -- in buildCarModel and hides these; if NO shell exists the procedural CarBody + -- kit supplies rims, so the cylinder is never the intended visual either way. local wheel = makePart({ Name = "Wheel_" .. cornerName, Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2), @@ -294,8 +310,7 @@ local function buildCarModel(car: Car, tuning: Tuning): Model -- 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.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 @@ -333,12 +348,24 @@ local function buildCarModel(car: Car, tuning: Tuning): Model 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 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)) @@ -372,8 +399,66 @@ local function buildCarModel(car: Car, tuning: Tuning): Model if lo2 then bottomOffset = lo2.Y - shell:GetPivot().Position.Y end + -- 2b. FACE THE SHELL FORWARD. PivotTo below forces the shell's pivot + -- orientation to the body's, so a shell inherits whatever yaw its + -- author happened to save it with -- there is no automatic + -- alignment. Three of the five library shells were modelled + -- nose-toward -Z (the chassis convention, since the front axle is + -- built at Z = -WHEELBASE/2) and looked right by luck; hatch_junk + -- and hyper_x were modelled nose-toward +Z and rendered BACKWARDS + -- on a correctly-built chassis. + -- Derive each shell's true forward from its OWN wheel names rather + -- than a hand-maintained per-car constant: F* wheels are the front + -- axle, R* the rear, so (front - rear) is the nose direction in the + -- shell's local frame. Solve the yaw that maps it onto -Z. This + -- also covers 90-degree-rotated shells, and self-corrects for any + -- shell added later. If the cue is missing or degenerate, rotate + -- nothing (fail soft to previous behaviour) rather than guess. + local shellYaw = 0 + local wheelsFolder = shell:FindFirstChild("Wheels") + if wheelsFolder then + local pivotInv = shell:GetPivot():Inverse() + local fSum, fN = Vector3.zero, 0 + local rSum, rN = Vector3.zero, 0 + for _, w in ipairs(wheelsFolder:GetChildren()) do + local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + if part then + local head = w.Name:sub(1, 1):upper() + local localPos = pivotInv * part.Position + if head == "F" then + fSum, fN = fSum + localPos, fN + 1 + elseif head == "R" then + rSum, rN = rSum + localPos, rN + 1 + end + end + end + if fN > 0 and rN > 0 then + local nose = (fSum / fN) - (rSum / rN) + -- Horizontal component only; a shell sitting nose-down must not + -- tilt the whole body. + if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then + shellYaw = -math.atan2(nose.X, -nose.Z) + end + end + end + -- Rotating about the pivot swings the bodywork sideways whenever the + -- pivot is not the geometric centre (hyper_x's centre sits 0.70 studs + -- off it, so a 180-degree flip would shift the shell 1.4 studs off the + -- chassis). Cancel that by re-centring the shell's horizontal AABB + -- centre onto the body axis; Y is untouched, so the ground alignment + -- solved above still holds (a yaw rotation cannot change world Y + -- extents). + local hi2 = select(2, trueAABB(shell)) + local centreFix = CFrame.new() + if lo2 and hi2 then + local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2) + centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z) + end shell:PivotTo( - body.CFrame * CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0) + body.CFrame + * CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0) + * CFrame.Angles(0, shellYaw, 0) + * centreFix ) -- 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 @@ -398,9 +483,11 @@ local function buildCarModel(car: Car, tuning: Tuning): Model 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)) + 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 @@ -411,8 +498,7 @@ local function buildCarModel(car: Car, tuning: Tuning): Model -- 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 + 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 @@ -420,19 +506,13 @@ local function buildCarModel(car: Car, tuning: Tuning): Model -- 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) + 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 + if w:IsA("WeldConstraint") and (w.Part1 == seatPart or w.Part0 == seatPart) then w:Destroy() end end @@ -461,6 +541,93 @@ local function buildCarModel(car: Car, tuning: Tuning): Model shell.Parent = model -- Hide the placeholder blocks the mesh replaces. body.Transparency = 1 + -- ...INCLUDING the physics wheels. The shell brings its own modelled + -- wheels, so leaving the chassis cylinders opaque puts TWO sets of + -- wheels on the car -- the mesh tyre plus a grey rubber cylinder over + -- it. That is what reads as absurdly wide tyres. They aren't even + -- concentric: the cylinder's outer edge sits at X=4.10 against the + -- gtr_r34 mesh tyre's 4.52, so the duplicate juts out past the real + -- wheel at a different track width. + -- This is deliberately INSIDE the shell branch. The procedural CarBody + -- fallback (else-branch) has no wheels of its own -- buildRims only + -- welds a small Rim disc INTO each Wheel_ part, at 1.2x radius versus + -- the wheel's 2x, and transparency is not inherited by children. Hide + -- these unconditionally and a shell-less car visibly rides on four + -- undersized floating discs. + for _, d in ipairs(model:GetChildren()) do + if d:IsA("BasePart") and d.Name:match("^Wheel_") then + d.Transparency = 1 + end + end + -- RE-HANG THE MODELLED WHEELS ON THE PHYSICS WHEELS. The imported + -- shells weld every wheel part straight to the body mesh (the template + -- weld graph is MB <-> FL, MB <-> Rotor, MB <-> Caliper, ...), so the + -- visible tyres are rigidly glued to the chassis: their assembly root + -- comes out as DriveSeat rather than the wheel. They never spin and + -- never steer -- painted-on hubcaps, which is most of why the wheels + -- looked wrong in motion. Cut those welds and re-weld each wheel group + -- to its own Wheel_ cylinder so it inherits that cylinder's spin, and + -- on the front axle its steer. + local wheelGroups = shell:FindFirstChild("Wheels") + if wheelGroups then + for _, w in ipairs(wheelGroups:GetChildren()) do + local phys = model:FindFirstChild("Wheel_" .. w.Name:upper()) + local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + if phys and root then + local group, inGroup = {}, {} + if w:IsA("BasePart") then + group[#group + 1] = w + inGroup[w] = true + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + group[#group + 1] = d + inGroup[d] = true + end + end + -- Drop only the welds that tie this group to the OUTSIDE + -- (i.e. to the bodywork). Welds wholly inside the group are + -- the wheel's own construction and must survive. + for _, c in ipairs(shell:GetDescendants()) do + if c:IsA("WeldConstraint") or c:IsA("Weld") then + local a, b = c.Part0, c.Part1 + local aIn, bIn = a ~= nil and inGroup[a], b ~= nil and inGroup[b] + if (aIn or bIn) and not (aIn and bIn) then + c:Destroy() + end + end + end + -- Make it CONCENTRIC with the cylinder it now rides on. + -- Each shell models its own track and wheelbase (the + -- gtr_r34 rear tyre sits 1.34 studs ahead of the chassis + -- axle, the fronts 0.33 inboard). A weld preserves relative + -- offset, so welding at that offset would make the tyre + -- ORBIT the axle at 1.34 studs radius instead of spinning + -- on it -- far worse than the static wheel it replaces. + local delta = phys.Position - root.Position + for _, p in ipairs(group) do + p.CFrame = p.CFrame + delta + end + -- The template hung every part off the body, so with those + -- welds cut the group has no internal rigidity left of its + -- own. Rebuild it around root (these parts are already + -- Massless + CanCollide=false, so this adds no physics + -- cost), then hang root off the cylinder. + for _, p in ipairs(group) do + if p ~= root then + local iw = Instance.new("WeldConstraint") + iw.Part0 = root + iw.Part1 = p + iw.Parent = root + end + end + local pw = Instance.new("WeldConstraint") + pw.Part0 = phys + pw.Part1 = root + pw.Parent = phys + end + end + end local cabin = model:FindFirstChild("Cabin") if cabin then cabin:Destroy() diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index 5213b68..7d6dac9 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -56,6 +56,60 @@ local C_PARAPET = Color3.fromRGB(226, 224, 218) local C_RAIL = Color3.fromRGB(96, 100, 106) local UP = Vector3.new(0, 1, 0) +-- REAL-WORLD SCALE ------------------------------------------------------------ +-- The rest of the codebase converts speed with 0.28 m per stud, so all the +-- metric road-standard figures below are converted through the same constant. +-- Sanity check: the 49,383-stud loop comes out at 13.8 km against the real +-- C1 Inner Circular Route's 14.8 km, so the circuit is very close to 1:1. +local M = 1 / 0.28 -- studs per metre (~3.571) + +-- Road markings per 全標協 (Japan Road Marking Assoc.) 2012 p.42 / 2018 pp.97-98, +-- read from the primary standard rather than repeated folklore: +-- * 車線境界線 (lane boundary, dashed): line length 3-10 m, gap = 1x to 2x the +-- line length, width 10-15 cm (15 cm "desirable"). The 8 m line / 12 m gap +-- used here is the high-speed end of that legal range (gap = 1.5x line) and +-- is the configuration expressways actually use -- it's what makes the +-- rhythm read as an expressway rather than a 5 m/5 m surface street. +-- * Where overtaking is undesirable (屈曲部 -- curves), the standard says the +-- line becomes SOLID rather than dashed. On expressways that no-lane-change +-- line is painted yellow. +-- * NOTE: the 45 cm width sometimes quoted for "expressway lines" is NOT the +-- lane boundary -- it's the boundary against a 変速車線 (accel/decel lane). +-- Applied below at ramps only. +local DASH_PAINT = 8 * M -- 28.6 studs +local DASH_GAP = 12 * M -- 42.9 studs +local DASH_CYCLE = DASH_PAINT + DASH_GAP -- 20 m +local LANE_LINE_W = 0.15 * M -- 0.54 studs +local EDGE_LINE_W = 0.20 * M -- 0.71 studs +local RAMP_LINE_W = 0.45 * M -- 1.61 studs; through lane vs speed-change lane +local LANE_W = 3.25 * M -- 11.6 studs; narrow urban lane used on the C1 +-- 導流帯 (gore/zebra) per 全標協 2018 pp.97-98: 45 cm white solid lines repeated +-- every 1.0 m, bounded by a 15-20 cm edge line. +local ZEBRA_W = 0.45 * M +local ZEBRA_PITCH = 1.0 * M +local MARK_Y = 0.1 -- sits just proud of the deck to avoid z-fighting +local MARK_H = 2.1 +-- Heading change per 60-stud sample above which the curve counts as tight +-- enough that the real road would paint a solid yellow no-lane-change line. +local TIGHT_CURVE = math.rad(4) + +local C_PAINT_WHITE = Color3.fromRGB(232, 232, 236) +local C_PAINT_YELLOW = Color3.fromRGB(240, 190, 40) -- 黄色 no-lane-change line + +-- Signage colours sampled from the official Japanese sign artwork (Wikimedia +-- Commons SVGs of the 標識令 plates), not eyeballed: +-- guide sign (案内標識) green #005D35 +-- Shutoko route badge green #007F1F +local C_SIGN_GREEN = Color3.fromRGB(0, 93, 53) +local C_BADGE_GREEN = Color3.fromRGB(0, 127, 31) +local C_SIGN_WHITE = Color3.fromRGB(255, 255, 255) + +-- Lighting. Japanese expressway/tunnel lighting is high-pressure sodium +-- (warm amber) with newer LED sections in cool white -- NOT the blue/teal +-- that reads as generic sci-fi. Tunnel basic lighting runs continuously. +local C_SODIUM = Color3.fromRGB(255, 176, 92) +local C_TUNNEL_LED = Color3.fromRGB(255, 244, 224) + local NEON_PINK = Color3.fromRGB(255, 40, 120) local NEON_BLUE = Color3.fromRGB(40, 160, 255) local NEON_TEAL = Color3.fromRGB(40, 230, 200) @@ -78,27 +132,105 @@ local function det(seed: number): number return x - math.floor(x) end +-- Real interchange names. Japanese is what's actually painted on the gantry; +-- the romaji underneath is the smaller secondary line every Shutoko guide sign +-- carries. Names cross-checked against OpenStreetMap's tagging of the real +-- Inner Circular Route (name / name:en on the motorway_junction nodes). +local JUNCTION_NAMES: { [string]: { string } } = { + ["Kyobashi"] = { "京橋", "Kyobashi" }, + ["Takaracho"] = { "宝町", "Takaracho" }, + ["Edobashi junction"] = { "江戸橋JCT", "Edobashi JCT" }, + ["Takebashi Junction"] = { "竹橋JCT", "Takebashi JCT" }, + ["Hitotsubashi"] = { "一ツ橋", "Hitotsubashi" }, + ["Kitanomaru"] = { "北の丸", "Kitanomaru" }, + ["Daikancho"] = { "代官町", "Daikancho" }, + ["Miyakezaka JCT"] = { "三宅坂JCT", "Miyakezaka JCT" }, + ["Miyakezaka Junction"] = { "三宅坂JCT", "Miyakezaka JCT" }, + ["Kasumigaseki"] = { "霞が関", "Kasumigaseki" }, + ["Tanimachi JCT"] = { "谷町JCT", "Tanimachi JCT" }, + ["Iikura"] = { "飯倉", "Iikura" }, + ["Shiodome"] = { "汐留", "Shiodome" }, + ["Ginza"] = { "銀座", "Ginza" }, + ["Shintomicho"] = { "新富町", "Shintomicho" }, +} + +-- Japanese expressway guide sign (案内標識). Green board, white text, white +-- hairline border inset from the edge, Japanese place name large with the +-- romaji reading beneath it, and the circular route badge on the left. local function makeSign(text: string, cf: CFrame, parent: Instance) + local names = JUNCTION_NAMES[text] or { text, text } + local ja, romaji = names[1], names[2] + local board = part({ Name = "Sign", Size = Vector3.new(34, 10, 1), CFrame = cf, - Color = Color3.fromRGB(18, 90, 60), + Color = C_SIGN_GREEN, Material = Enum.Material.SmoothPlastic, }) board.Parent = parent + local gui = Instance.new("SurfaceGui") gui.Face = Enum.NormalId.Front - gui.CanvasSize = Vector2.new(600, 180) + gui.CanvasSize = Vector2.new(680, 200) gui.Parent = board - local label = Instance.new("TextLabel") - label.Size = UDim2.fromScale(1, 1) - label.BackgroundTransparency = 1 - label.Font = Enum.Font.GothamBold - label.TextColor3 = Color3.new(1, 1, 1) - label.TextScaled = true - label.Text = text - label.Parent = gui + + -- Thin white border inset from the panel edge -- present on every real + -- Japanese guide sign and a big part of why they read as "Japanese". + local border = Instance.new("Frame") + border.Size = UDim2.new(1, -24, 1, -24) + border.Position = UDim2.fromOffset(12, 12) + border.BackgroundTransparency = 1 + border.BorderSizePixel = 3 + border.BorderColor3 = C_SIGN_WHITE + border.Parent = gui + + -- Route badge: the C1 shield, green disc with white ring and "C1". + local badge = Instance.new("Frame") + badge.Size = UDim2.fromOffset(104, 104) + badge.Position = UDim2.fromOffset(34, 48) + badge.BackgroundColor3 = C_BADGE_GREEN + badge.BorderSizePixel = 0 + badge.Parent = gui + local badgeCorner = Instance.new("UICorner") + badgeCorner.CornerRadius = UDim.new(1, 0) + badgeCorner.Parent = badge + local badgeStroke = Instance.new("UIStroke") + badgeStroke.Color = C_SIGN_WHITE + badgeStroke.Thickness = 5 + badgeStroke.Parent = badge + local badgeText = Instance.new("TextLabel") + badgeText.Size = UDim2.fromScale(1, 1) + badgeText.BackgroundTransparency = 1 + badgeText.Font = Enum.Font.GothamBold + badgeText.TextColor3 = C_SIGN_WHITE + badgeText.TextScaled = true + badgeText.Text = "C1" + badgeText.Parent = badge + + -- Japanese name (primary, large) + local jaLabel = Instance.new("TextLabel") + jaLabel.Size = UDim2.new(1, -190, 0, 96) + jaLabel.Position = UDim2.fromOffset(168, 36) + jaLabel.BackgroundTransparency = 1 + jaLabel.Font = Enum.Font.GothamBold + jaLabel.TextColor3 = C_SIGN_WHITE + jaLabel.TextScaled = true + jaLabel.TextXAlignment = Enum.TextXAlignment.Left + jaLabel.Text = ja + jaLabel.Parent = gui + + -- Romaji reading (secondary, smaller, beneath) + local enLabel = Instance.new("TextLabel") + enLabel.Size = UDim2.new(1, -190, 0, 44) + enLabel.Position = UDim2.fromOffset(168, 132) + enLabel.BackgroundTransparency = 1 + enLabel.Font = Enum.Font.Gotham + enLabel.TextColor3 = C_SIGN_WHITE + enLabel.TextScaled = true + enLabel.TextXAlignment = Enum.TextXAlignment.Left + enLabel.Text = romaji + enLabel.Parent = gui end -- PA junction planning ----------------------------------------------------------- @@ -157,6 +289,10 @@ local function buildRoad(map: Folder) local n = #samples local plan = planPA() + -- Running arc length around the loop, so lane dashes keep a continuous + -- 20 m rhythm instead of restarting at every sample. + local segStart = 0 + for i = 1, n do local a = samples[i] local b = samples[(i % n) + 1] @@ -172,6 +308,16 @@ local function buildRoad(map: Folder) local right = flatDir:Cross(UP) local cf = CFrame.lookAt(mid, mid + dir, UP) + -- How hard this segment turns, used to decide where the real road + -- would forbid lane changes (solid yellow) rather than allow them. + local prev = samples[((i - 2) % n) + 1] + local inDir = (a.pos - prev.pos) + inDir = Vector3.new(inDir.X, 0, inDir.Z) + local curvature = 0 + if inDir.Magnitude > 0.01 then + curvature = math.acos(math.clamp(inDir.Unit:Dot(flatDir), -1, 1)) + end + local deck = part({ Name = "Road_" .. i, Size = Vector3.new(ROAD_WIDTH, 2, len), @@ -211,28 +357,66 @@ local function buildRoad(map: Folder) 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 + -- C1 is a 2-lane-per-direction urban expressway (confirmed against + -- OSM tagging of 首都高速都心環状線: lanes=2, maxspeed=50). Lane width + -- is the narrow urban 3.25 m rather than the 3.5 m used on the + -- Bayshore, which is what leaves so little shoulder on the real road. + -- + -- Dashes are driven by ARC LENGTH along the circuit, not by sample + -- index. That matters: samples are 60 studs (16.8 m) apart but the + -- legal dash cycle is 20 m, so an index-based dash can never land on + -- the right rhythm -- it has to be computed in metres and clipped to + -- each segment. + local cwMid = MED_HALF + LANE_W + -- Lane changes are prohibited (yellow line) through the tunnels and + -- on the tight curves, which on the real C1 is most of the loop. + local laneColor = (a.tunnel or curvature > TIGHT_CURVE) and C_PAINT_YELLOW or C_PAINT_WHITE + for _, s in ipairs({ -1, 1 }) do + -- 車道外側線: solid, continuous, 0.20 m wide. 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), + Size = Vector3.new(EDGE_LINE_W, MARK_H, len), + CFrame = cf * CFrame.new(s * (HALF - 1.6), MARK_Y, 0), + Color = C_PAINT_WHITE, 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 * cwMid, 0.1, 0), - Color = Color3.fromRGB(220, 220, 225), + + -- 車線境界線 between the two lanes. + if laneColor == C_PAINT_YELLOW then + -- Yellow no-lane-change line is SOLID, not dashed. + local solid = part({ + Name = "LaneLine", + Size = Vector3.new(LANE_LINE_W, MARK_H, len), + CFrame = cf * CFrame.new(s * cwMid, MARK_Y, 0), + Color = C_PAINT_YELLOW, Material = Enum.Material.SmoothPlastic, }) - dash.Parent = road + solid.Parent = road + else + -- Dashed: paint the parts of the 20 m cycle that fall inside + -- this segment. segStart is the distance travelled around the + -- loop, so the rhythm stays continuous across segment joins. + local segLen = delta.Magnitude + local k = math.floor(segStart / DASH_CYCLE) + while k * DASH_CYCLE < segStart + segLen do + local pStart = math.max(k * DASH_CYCLE, segStart) + local pEnd = math.min(k * DASH_CYCLE + DASH_PAINT, segStart + segLen) + if pEnd > pStart then + -- Convert back to an offset from the segment centre. + local mid01 = ((pStart + pEnd) / 2 - segStart) / segLen + local dash = part({ + Name = "Dash", + Size = Vector3.new(LANE_LINE_W, MARK_H, pEnd - pStart), + CFrame = cf * CFrame.new(s * cwMid, MARK_Y, (mid01 - 0.5) * segLen), + Color = C_PAINT_WHITE, + Material = Enum.Material.SmoothPlastic, + }) + dash.Parent = road + end + k += 1 + end end end @@ -303,16 +487,18 @@ local function buildRoad(map: Folder) Material = Enum.Material.Concrete, }) ceil.Parent = struct - if i % 2 == 0 then - local strip = part({ - Name = "TunnelLight", - Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, 2.5), - CFrame = cf * CFrame.new(0, h - 0.5, 0), - Color = (i % 6 == 0) and NEON_BLUE or NEON_TEAL, - Material = Enum.Material.Neon, - }) - strip.Parent = struct - end + -- Tunnel lighting is high-pressure sodium (warm amber) with newer + -- LED sections in cool white. Real tunnel "basic lighting" is a + -- continuous run, not isolated dots, so the strip is placed on + -- every segment rather than every other one. + local strip = part({ + Name = "TunnelLight", + Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, len), + CFrame = cf * CFrame.new(0, h - 0.5, 0), + Color = (i % 8 < 4) and C_SODIUM or C_TUNNEL_LED, + Material = Enum.Material.Neon, + }) + strip.Parent = struct else -- Elevated viaduct: support pillars down to street level if i % 3 == 0 and a.pos.Y > 18 then @@ -325,17 +511,38 @@ local function buildRoad(map: Folder) }) pil.Parent = struct end - -- sodium lamps along the deck - if i % 4 == 0 then + -- Sodium lamps along the deck. Japanese expressway lighting is + -- warm high-pressure sodium on a ~35 m pole pitch; samples are + -- 60 studs (16.8 m) apart so every 2nd sample ~= 33.6 m. + if i % 2 == 0 then local lamp = part({ Name = "RoadLight", Size = Vector3.new(1.4, 1.4, 1.4), CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2) + Vector3.new(0, 5.5, 0)), - Color = NEON_AMBER, + Color = C_SODIUM, Material = Enum.Material.Neon, }) lamp.Parent = struct end + -- Delineators (視線誘導標): small reflective posts on the outer + -- edge that mark the road's line ahead at night. Real spacing on + -- curves is tight; ~17 m here. + local delin = part({ + Name = "Delineator", + Size = Vector3.new(0.5, 1.1, 0.5), + CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2 - 2.6) + Vector3.new(0, 2.5, 0)), + Color = C_PAINT_WHITE, + Material = Enum.Material.SmoothPlastic, + }) + delin.Parent = struct + local eye = part({ + Name = "DelineatorEye", + Size = Vector3.new(0.36, 0.36, 0.36), + CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2 - 2.6) + Vector3.new(0, 3.0, 0)), + Color = Color3.fromRGB(255, 190, 80), + Material = Enum.Material.Neon, + }) + eye.Parent = struct if i % 9 == 0 then local pylon = part({ Name = "Pylon", @@ -347,9 +554,128 @@ local function buildRoad(map: Folder) pylon.Parent = struct end end + + -- 導流帯 / gore zebra at the PA ramp junctions -------------------- + -- Where the ramp splits off the mainline the real road paints a + -- zebra-hatched no-drive wedge in the gore. 45 cm bars every 1.0 m + -- per 全標協 2018 pp.97-98. + if plan.medianOpen[i] then + local nBars = math.floor(delta.Magnitude / ZEBRA_PITCH) + for k = 0, nBars - 1 do + local z = (k * ZEBRA_PITCH - delta.Magnitude / 2) + ZEBRA_PITCH / 2 + for _, s in ipairs({ -1, 1 }) do + local bar = part({ + Name = "GoreZebra", + Size = Vector3.new(6, MARK_H, ZEBRA_W), + CFrame = cf * CFrame.new(s * (HALF - 5), MARK_Y, z), + Color = C_PAINT_WHITE, + Material = Enum.Material.SmoothPlastic, + }) + bar.Parent = road + end + end + -- 45 cm boundary line separating the through lane from the + -- speed-change lane, per 全標協 2012 p.42. + for _, s in ipairs({ -1, 1 }) do + local rl = part({ + Name = "RampBoundary", + Size = Vector3.new(RAMP_LINE_W, MARK_H, len), + CFrame = cf * CFrame.new(s * (HALF - 8.5), MARK_Y, 0), + Color = C_PAINT_WHITE, + Material = Enum.Material.SmoothPlastic, + }) + rl.Parent = road + end + end + + -- Advance the running arc length so the next segment's dashes + -- continue the 20 m rhythm instead of restarting. + segStart += delta.Magnitude end end +-- Regulatory speed limit sign (規制標識 323 最高速度) ----------------------------- +-- Circular, white face, red annulus, black numerals. The real C1 is posted at +-- 50 km/h (40 in the tightest junction sections) -- verified against the route +-- article's own signage table, not the 80 km/h used on the Bayshore Route. +local function makeSpeedSign(kmh: number, cf: CFrame, parent: Instance) + local post = part({ + Name = "SignPost", + Size = Vector3.new(0.6, 12, 0.6), + CFrame = cf * CFrame.new(0, -6, 0), + Color = C_RAIL, + Material = Enum.Material.Metal, + }) + post.Parent = parent + + local face = part({ + Name = "SpeedLimitSign", + Size = Vector3.new(6, 6, 0.4), + CFrame = cf, + Color = Color3.fromRGB(255, 255, 255), + Material = Enum.Material.SmoothPlastic, + Shape = Enum.PartType.Cylinder, + }) + -- Cylinder's flat faces point along X, so rotate it to face the driver. + face.CFrame = cf * CFrame.Angles(0, math.rad(90), 0) + face.Parent = parent + + for _, faceId in ipairs({ Enum.NormalId.Right, Enum.NormalId.Left }) do + local gui = Instance.new("SurfaceGui") + gui.Face = faceId + gui.CanvasSize = Vector2.new(256, 256) + gui.Parent = face + + local disc = Instance.new("Frame") + disc.Size = UDim2.fromScale(1, 1) + disc.BackgroundColor3 = Color3.fromRGB(255, 255, 255) + disc.BorderSizePixel = 0 + disc.Parent = gui + local dc = Instance.new("UICorner") + dc.CornerRadius = UDim.new(1, 0) + dc.Parent = disc + -- Red annulus is ~11% of the diameter on the JP plate. + local ring = Instance.new("UIStroke") + ring.Color = Color3.fromRGB(200, 30, 45) + ring.Thickness = 28 + ring.Parent = disc + + local num = Instance.new("TextLabel") + num.Size = UDim2.fromScale(0.72, 0.56) + num.Position = UDim2.fromScale(0.14, 0.22) + num.BackgroundTransparency = 1 + num.Font = Enum.Font.GothamBold + num.TextColor3 = Color3.fromRGB(20, 20, 20) + num.TextScaled = true + num.Text = tostring(kmh) + num.Parent = disc + end +end + +-- Kilometre post (キロポスト) ---------------------------------------------------- +local function makeKmPost(km: number, cf: CFrame, parent: Instance) + local board = part({ + Name = "KmPost", + Size = Vector3.new(2.6, 3.2, 0.3), + CFrame = cf, + Color = C_SIGN_GREEN, + Material = Enum.Material.SmoothPlastic, + }) + board.Parent = parent + local gui = Instance.new("SurfaceGui") + gui.Face = Enum.NormalId.Front + gui.CanvasSize = Vector2.new(130, 160) + gui.Parent = board + local label = Instance.new("TextLabel") + label.Size = UDim2.fromScale(1, 1) + label.BackgroundTransparency = 1 + label.Font = Enum.Font.GothamBold + label.TextColor3 = C_SIGN_WHITE + label.TextScaled = true + label.Text = string.format("%.1f", km) + label.Parent = gui +end + -- Junction gantry signs at each real interchange --------------------------------- local function buildJunctionSigns(map: Folder) local folder = Instance.new("Folder") @@ -369,6 +695,33 @@ local function buildJunctionSigns(map: Folder) local pos = s.pos + Vector3.new(0, 15, 0) makeSign(s.piece, CFrame.lookAt(pos, pos - fwd, UP), folder) end + + -- Speed limit repeaters and kilometre posts around the loop. + local dist = 0 + local nextKm = 0 + for i = 1, n do + local a = samples[i] + local b = samples[(i % n) + 1] + local seg = (b.pos - a.pos).Magnitude + local fwd = Circuit.forwardAt(i) + local flat = Vector3.new(fwd.X, 0, fwd.Z) + flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis + local right = flat:Cross(UP) + + -- C1 is posted at 50 km/h; repeat roughly every 1 km, off to the side. + if i % 60 == 0 and not a.tunnel then + local p = a.pos + right * (ROAD_WIDTH / 2 - 3) + Vector3.new(0, 9, 0) + makeSpeedSign(50, CFrame.lookAt(p, p - flat, UP), folder) + end + + -- Kilometre posts every 0.5 km of real distance (0.5 km = 1785 studs). + if dist >= nextKm * 500 * M then + local p = a.pos + right * (ROAD_WIDTH / 2 - 2) + Vector3.new(0, 4, 0) + makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder) + nextKm += 1 + end + dist += seg + end end -- Daikoku-style PA --------------------------------------------------------------- diff --git a/src/shared/Chassis.luau b/src/shared/Chassis.luau index 2bf9cd3..7e9963c 100644 --- a/src/shared/Chassis.luau +++ b/src/shared/Chassis.luau @@ -49,6 +49,28 @@ Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed +-- Steering knuckle ------------------------------------------------------------ +-- The knuckle must be a SUBSTANTIAL body, not a token 0.5 cube. The steering +-- servo yaws the knuckle, and the knuckle has to carry the wheel (mass 5.67) +-- against tyre-to-ground friction. At the old 0.5 size the knuckle massed +-- 0.087 -- 65x LIGHTER than the wheel hanging off it -- and the servo simply +-- lost: measured under real controller input, CurrentAngle missed TargetAngle +-- by a mean of 32 deg (peak 72 deg) and broke through the servo's own +-- LimitsEnabled bounds on 6 of 20 samples. A constraint blowing past its hard +-- limits is the signature of an under-massed link, not of a weak motor: +-- raising ServoMaxTorque cannot fix it because the torque was never the +-- binding constraint. +-- +-- Measured sweep (full lock, real SteerFloat input, gtr_r34): +-- size 0.5 (mass 0.09) -> mean err 32.2 deg, 6/20 limit violations, drives +-- size 1.6 (mass 2.87) -> mean err 2.2 deg, 1/20 violations, drives +-- density 80 (mass 328) -> mean err 0.1 deg, 0/20 violations, CAR FROZEN +-- Mass is what buys control, but overshoot it and the knuckles outweigh the +-- 125-mass body and the car cannot move at all. 1.6 studs at stock density +-- puts the knuckle in the same order of magnitude as its wheel, which is the +-- physical relationship that matters. Do NOT "improve" this with density. +Chassis.KNUCKLE_SIZE = 1.6 + -- Cabin fit -------------------------------------------------------------------- -- The driver used to stick out of the roof for THREE compounding reasons, all -- fixed here + in CarManager: