diff --git a/docs/japanese-road-standards.md b/docs/japanese-road-standards.md index 99a523a..03fc729 100644 --- a/docs/japanese-road-standards.md +++ b/docs/japanese-road-standards.md @@ -43,7 +43,7 @@ blue. Shutoko uses green. | Route | Posted | |---|---| -| C1 都心環状線 (Inner Circular) | **50 km/h**, dropping to **40 km/h** in the tightest junction sections (e.g. Edobashi JCT) | +| C1 都心環状線 (Inner Circular) | **50 km/h** mainline, **40 km/h** inside the junction sections — 江戸橋JCT内, 浜崎橋JCT内, and 京橋JCT–旧白魚橋乗継所 are each posted 40 in the route's own 車線・最高速度 table | | 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 @@ -58,6 +58,36 @@ posted 50, not 80. photometry. Porous asphalt (排水性舗装) does read darker and coarser than ordinary asphalt, which the palette reflects. +## Structural sections of the C1 (drives MapBuilder geometry) + +The C1 is not a uniform elevated tube — it changes structural type along its +length, and the circuit now renders each type differently. + +| Section | Real-world form | In-game | +|---|---|---| +| Elevated viaduct (most of the loop) | Deck on concrete columns | `Pillar` every 3rd sample above Y=18 | +| Bridge spans (474 flagged samples) | Steel box girder 鋼箱桁橋 over the old canals | `BridgeGirder` + `GirderRib` under deck; `PierCap` + round `PierColumn` on a 6-sample pitch | +| Ginza 銀座 | Runs **below** street level in the filled-in 楓川・築地川 river beds; surviving city bridges cross **over** it, piers landing in the median | `GinzaOverpass` + `OverpassParapet`, `OverpassPier` in the median | +| Tunnel sections | Bored/cut-and-cover | Tunnel bore, ceiling, no sky lighting | + +Girder depth is set from the span/depth rule of thumb for steel box girders +(≈1/20 of span): at ~35 m spans that is ~1.8 m ≈ 6.4 studs (`GIRDER_H`). + +**Why Ginza bans lane changes.** The no-lane-change marking through Ginza is not +arbitrary styling — the crossing bridges' piers physically occupy the median +between the carriageways, so the lanes are separated by structure. The yellow +line and the `OverpassPier` geometry are two expressions of the same fact. + +## Lane counts + +| Section | Lanes | +|---|---| +| Most of C1 | 2 + 2 | +| Shiodome JCT → Hamazakibashi JCT | **3 + 3** (widened; carries the merge from the Haneda line) | + +Rendered by `widthAt(i)`: 60 studs baseline, 82 studs in `WIDE_ZONES`, with a +taper between so the widening never steps more than ~1.7 studs per sample. + ## Method note Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index 7d6dac9..acc76b3 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -49,11 +49,16 @@ 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 +-- Steel box-girder depth under bridge decks (鋼箱桁). Real Shutoko girders run +-- roughly 1/20 of the span depth; at ~35 m spans that's ~1.8 m => ~6.4 studs. +local GIRDER_H = 6.4 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) +-- Weathered grey-green steel of the box girders under the elevated sections. +local C_GIRDER = Color3.fromRGB(122, 128, 124) local UP = Vector3.new(0, 1, 0) -- REAL-WORLD SCALE ------------------------------------------------------------ @@ -115,6 +120,70 @@ local NEON_BLUE = Color3.fromRGB(40, 160, 255) local NEON_TEAL = Color3.fromRGB(40, 230, 200) local NEON_AMBER = Color3.fromRGB(255, 170, 60) +-- SECTION PROFILE (real C1 lane counts and posted speeds) --------------------- +-- The C1 is NOT uniform. Verified against the 車線・最高速度 table in the +-- 首都高速都心環状線 article: +-- +-- 江戸橋JCT内 2 = 1+1 40 km/h +-- 江戸橋JCT - 汐留JCT 4 = 2+2 50 km/h +-- 汐留JCT - 浜崎橋JCT 6 = 3+3 50 km/h <-- widest section +-- 浜崎橋JCT内 4 = 2+2 40 km/h +-- 浜崎橋JCT - 江戸橋JCT 4 = 2+2 50 km/h +-- 京橋JCT - 旧白魚橋乗継所 3 = 1+2 40 km/h +-- +-- So junction interiors are posted 40, mainline 50, and the Shiodome -> +-- Hamazakibashi run carries three lanes each way rather than two. +local JCT_ZONES: { [string]: boolean } = { + ["Edobashi junction"] = true, + ["Takebashi Junction"] = true, + ["Miyakezaka JCT"] = true, + ["Miyakezaka Junction"] = true, + ["Tanimachi JCT"] = true, +} +-- 汐留JCT - 浜崎橋JCT: 6 lanes (3+3). +local WIDE_ZONES: { [string]: boolean } = { ["Shiodome"] = true } +-- 銀座: the 半地下 section built in the filled-in 楓川/築地川 river beds. Several +-- of the original river bridges survive as 跨道橋 crossing OVER the expressway, +-- and their piers stand BETWEEN the running lanes -- which is why the real road +-- bans lane changes through here. +local GINZA_ZONES: { [string]: boolean } = { ["Ginza"] = true } + +-- Deck width needed for 3+3: 6 x 3.25 m lanes + median + shoulders. +local WIDE_ROAD_WIDTH = 82 +local MAX_ROAD_WIDTH = math.max(ROAD_WIDTH, WIDE_ROAD_WIDTH) + +local function zoneOf(i: number): string + return Circuit.samples[i].piece +end +local function isJctSample(i: number): boolean + return JCT_ZONES[zoneOf(i)] == true +end +local function lanesAt(i: number): number + return WIDE_ZONES[zoneOf(i)] and 3 or 2 +end + +-- Per-sample deck width, smoothed so the carriageway tapers into and out of the +-- 3+3 section instead of stepping by 22 studs between two adjacent segments. +local ROAD_W: { number } = {} +do + local n = #Circuit.samples + local base: { number } = {} + for i = 1, n do + base[i] = WIDE_ZONES[Circuit.samples[i].piece] and WIDE_ROAD_WIDTH or ROAD_WIDTH + end + local TAPER = 6 + for i = 1, n do + local acc = 0 + for k = -TAPER, TAPER do + acc += base[((i - 1 + k) % n) + 1] + end + ROAD_W[i] = acc / (2 * TAPER + 1) + end +end +local function widthAt(i: number): number + return ROAD_W[i] +end + local function part(props: { [string]: any }): Part local p = Instance.new("Part") p.Anchored = true @@ -320,7 +389,7 @@ local function buildRoad(map: Folder) local deck = part({ Name = "Road_" .. i, - Size = Vector3.new(ROAD_WIDTH, 2, len), + Size = Vector3.new(widthAt(i), 2, len), CFrame = cf, Color = C_ASPHALT, Material = Enum.Material.Asphalt, @@ -331,7 +400,7 @@ local function buildRoad(map: Folder) -- 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 HALF = widthAt(i) / 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). @@ -356,21 +425,23 @@ local function buildRoad(map: Folder) fence.Parent = struct end - -- LANE MARKINGS: 2 lanes per carriageway ---------------------------- - -- 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. + -- LANE MARKINGS ----------------------------------------------------- + -- Lane count varies by section (see SECTION PROFILE above): most of the + -- C1 is 2+2, but 汐留JCT-浜崎橋JCT is 3+3. A 3-lane carriageway needs + -- TWO boundary lines, not one, so the markings are generated per lane + -- boundary rather than assuming a single centre dash. -- -- 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 + local nLanes = lanesAt(i) + -- Lane changes are prohibited (yellow line) through the tunnels, on the + -- tight curves, and through Ginza -- where 跨道橋 piers stand between + -- the lanes, which is exactly why the real road forbids it there. + local noLaneChange = a.tunnel or curvature > TIGHT_CURVE or GINZA_ZONES[a.piece] == true + local laneColor = noLaneChange and C_PAINT_YELLOW or C_PAINT_WHITE for _, s in ipairs({ -1, 1 }) do -- 車道外側線: solid, continuous, 0.20 m wide. @@ -383,39 +454,42 @@ local function buildRoad(map: Folder) }) edge.Parent = road - -- 車線境界線 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, - }) - 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 + -- 車線境界線: one per boundary between adjacent lanes. + for lane = 1, nLanes - 1 do + local cwMid = MED_HALF + LANE_W * lane + 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, + }) + 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 - k += 1 end end end @@ -469,7 +543,7 @@ local function buildRoad(map: Folder) -- Yamate/C1-style bore: walls, ceiling, strip lighting local h = 24 for _, side in ipairs({ -1, 1 }) do - local woff = right * (side * (ROAD_WIDTH / 2 + 1.5)) + local woff = right * (side * (HALF + 1.5)) local w = part({ Name = "TunnelWall", Size = Vector3.new(3, h, len + 2), @@ -481,7 +555,7 @@ local function buildRoad(map: Folder) end local ceil = part({ Name = "TunnelCeiling", - Size = Vector3.new(ROAD_WIDTH + 6, 2, len + 2), + Size = Vector3.new(widthAt(i) + 6, 2, len + 2), CFrame = cf * CFrame.new(0, h + 1, 0), Color = Color3.fromRGB(30, 30, 38), Material = Enum.Material.Concrete, @@ -493,15 +567,68 @@ local function buildRoad(map: Folder) -- every segment rather than every other one. local strip = part({ Name = "TunnelLight", - Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, len), + Size = Vector3.new(widthAt(i) * 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 + -- BRIDGE / VIADUCT SUPERSTRUCTURE ------------------------------ + -- Circuit.luau flags 474 samples as bridge=1, but nothing was ever + -- reading that flag -- every elevated span rendered as the same + -- plain slab on square columns. On the real C1 the flagged + -- sections are steel box-girder spans (鋼箱桁橋) over the old + -- river/canal alignments: a deep girder under the deck, sitting on + -- a transverse pier cap carried by round columns on a wider pitch. + if a.bridge then + -- Deep girder immediately under the deck -- this is the single + -- most recognisable feature of a Shutoko viaduct in profile. + local girder = part({ + Name = "BridgeGirder", + Size = Vector3.new(widthAt(i) * 0.72, GIRDER_H, len), + CFrame = cf * CFrame.new(0, -1 - GIRDER_H / 2, 0), + Color = C_GIRDER, + Material = Enum.Material.Metal, + }) + girder.Parent = struct + -- Girders are fabricated in segments; the transverse stiffener + -- ribs between them are what give the underside its rhythm. + local rib = part({ + Name = "GirderRib", + Size = Vector3.new(widthAt(i) * 0.78, GIRDER_H * 0.82, 1.2), + CFrame = cf * CFrame.new(0, -1 - GIRDER_H / 2, len / 2), + Color = C_GIRDER, + Material = Enum.Material.Metal, + }) + rib.Parent = struct + + -- Piers on a wider pitch than the viaduct columns, with the + -- pier cap (橋脚梁) spanning the full deck width. + if i % 6 == 0 and a.pos.Y > 14 then + local capY = a.pos.Y - 1 - GIRDER_H - 1.6 + local cap = part({ + Name = "PierCap", + Size = Vector3.new(widthAt(i) * 0.92, 3.2, 7), + CFrame = CFrame.new(mid.X, capY, mid.Z) * CFrame.Angles(0, math.atan2(flatDir.X, flatDir.Z), 0), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + }) + cap.Parent = struct + local col = part({ + Name = "PierColumn", + Size = Vector3.new(8, capY - 1.6, 8), + CFrame = CFrame.new(mid.X, (capY - 1.6) / 2, mid.Z), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + Shape = Enum.PartType.Cylinder, + }) + -- Cylinder's axis is X, so stand it upright. + col.CFrame = CFrame.new(mid.X, (capY - 1.6) / 2, mid.Z) * CFrame.Angles(0, 0, math.rad(90)) + col.Parent = struct + end -- Elevated viaduct: support pillars down to street level - if i % 3 == 0 and a.pos.Y > 18 then + elseif i % 3 == 0 and a.pos.Y > 18 then local pil = part({ Name = "Pillar", Size = Vector3.new(9, a.pos.Y, 9), @@ -518,7 +645,7 @@ local function buildRoad(map: Folder) 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)), + CFrame = CFrame.new(mid + right * HALF + Vector3.new(0, 5.5, 0)), Color = C_SODIUM, Material = Enum.Material.Neon, }) @@ -530,7 +657,7 @@ local function buildRoad(map: Folder) 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)), + CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 2.5, 0)), Color = C_PAINT_WHITE, Material = Enum.Material.SmoothPlastic, }) @@ -538,7 +665,7 @@ local function buildRoad(map: Folder) 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)), + CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 3.0, 0)), Color = Color3.fromRGB(255, 190, 80), Material = Enum.Material.Neon, }) @@ -547,7 +674,7 @@ local function buildRoad(map: Folder) local pylon = part({ Name = "Pylon", Size = Vector3.new(1, 14, 1), - CFrame = CFrame.new(mid - right * (ROAD_WIDTH / 2 + 3) + Vector3.new(0, 7, 0)), + CFrame = CFrame.new(mid - right * (HALF + 3) + Vector3.new(0, 7, 0)), Color = (i % 18 == 0) and NEON_PINK or NEON_BLUE, Material = Enum.Material.Neon, }) @@ -555,6 +682,49 @@ local function buildRoad(map: Folder) end end + -- GINZA 跨道橋 (bridges crossing OVER the expressway) ---------------- + -- The Ginza section runs in the filled-in 楓川・築地川 river beds, so it + -- sits BELOW street level and the surviving city bridges cross OVER it. + -- Their piers land in the median, physically between the carriageways -- + -- which is the real reason lane changes are banned here (the yellow + -- line above is driven by GINZA_ZONES for exactly this reason). + -- Placed outside the tunnel/viaduct branch: Ginza has 0 bridge-flagged + -- samples, so nesting this under `a.bridge` would never render it. + if GINZA_ZONES[a.piece] and i % 7 == 0 then + local deckY = a.pos.Y + 26 + local yaw = math.atan2(flatDir.X, flatDir.Z) + local ov = part({ + Name = "GinzaOverpass", + Size = Vector3.new(widthAt(i) + 70, 3.4, 26), + CFrame = CFrame.new(mid.X, deckY, mid.Z) * CFrame.Angles(0, yaw, 0), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + }) + ov.Parent = struct + for _, es in ipairs({ -1, 1 }) do + local par = part({ + Name = "OverpassParapet", + Size = Vector3.new(widthAt(i) + 70, 3.6, 1.4), + CFrame = CFrame.new(mid.X, deckY + 3.4, mid.Z) + * CFrame.Angles(0, yaw, 0) + * CFrame.new(0, 0, es * 12.3), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + par.Parent = struct + end + -- Centre pier, standing in the median between the directions. + local pierH = deckY - a.pos.Y - 1.7 + local pier = part({ + Name = "OverpassPier", + Size = Vector3.new(4.2, pierH, 9), + CFrame = CFrame.new(mid.X, a.pos.Y + pierH / 2 + 1, mid.Z) * CFrame.Angles(0, yaw, 0), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + }) + pier.Parent = struct + 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 @@ -708,15 +878,21 @@ local function buildJunctionSigns(map: Folder) 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) + -- Posted speed: 50 km/h on the mainline, but 40 km/h inside the + -- junction sections (江戸橋JCT内, 浜崎橋JCT内, 京橋JCT-旧白魚橋乗継所 + -- are all posted 40 in the route's own 車線・最高速度 table). Signs + -- repeat roughly every 1 km; junction plates go up more often because + -- the restricted stretches are short. + local jct = isJctSample(i) + local every = jct and 24 or 60 + if i % every == 0 and not a.tunnel then + local p = a.pos + right * (widthAt(i) / 2 - 3) + Vector3.new(0, 9, 0) + makeSpeedSign(jct and 40 or 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) + local p = a.pos + right * (widthAt(i) / 2 - 2) + Vector3.new(0, 4, 0) makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder) nextKm += 1 end @@ -1150,7 +1326,9 @@ local function buildSkyline(map: Folder) -- put it through the tunnel bore (walls sit at ROAD_WIDTH/2 + 1.5 + 3). -- Sample every point too -- striding by 3 skipped the closest approach on -- tight corners. - local CLEAR = ROAD_WIDTH / 2 + 60 + -- Use the WIDEST deck (3+3 Shiodome section), otherwise buildings sited + -- against the 2+2 width would intrude into the widened carriageway. + local CLEAR = MAX_ROAD_WIDTH / 2 + 60 local near = math.huge for k = 1, n do local s = samples[k].pos @@ -1347,7 +1525,7 @@ local function buildBridges(map: Folder) local cf = CFrame.lookAt(m, m + fwd, UP) local g = part({ Name = "BoxGirder", - Size = Vector3.new(ROAD_WIDTH + 10, 9, 64), + Size = Vector3.new(widthAt(i) + 10, 9, 64), CFrame = cf * CFrame.new(0, -6, 0), Color = Color3.fromRGB(64, 64, 72), Material = Enum.Material.Metal, @@ -1462,7 +1640,7 @@ local function buildBridges(map: Folder) local fwd = Circuit.forwardAt(i) local g = part({ Name = "BridgeGirder", - Size = Vector3.new(ROAD_WIDTH + 14, 7, 64), + Size = Vector3.new(widthAt(i) + 14, 7, 64), CFrame = CFrame.lookAt(m, m + fwd, UP) * CFrame.new(0, -5, 0), Color = RED, Material = Enum.Material.Metal, @@ -1504,7 +1682,7 @@ function MapBuilder.build(): Folder local fwd = Circuit.forwardAt(1) local line = part({ Name = "FinishLine", - Size = Vector3.new(ROAD_WIDTH, 0.6, 8), + Size = Vector3.new(widthAt(1), 0.6, 8), CFrame = CFrame.lookAt(s1.pos + Vector3.new(0, 1.4, 0), s1.pos + Vector3.new(0, 1.4, 0) + fwd, UP), Color = Color3.fromRGB(250, 250, 255), Material = Enum.Material.Neon, diff --git a/tools/diag_raycast.luau b/tools/diag_raycast.luau new file mode 100644 index 0000000..ca6931f --- /dev/null +++ b/tools/diag_raycast.luau @@ -0,0 +1,69 @@ +-- Diagnose the raycast outlier: report WHICH parts the rays hit. +-- The v3 raycast filtered on the whole map, so overhead geometry (overpass +-- decks, girders, gantries, tunnel ceilings) can be hit before the road. +local sss = game:GetService("ServerScriptService") +local orig = sss:FindFirstChild("MapBuilder", true) +local clone = orig:Clone() +clone.Name = "MapBuilder_Diag" +clone.Parent = orig.Parent +local MB = require(clone) +local map = MB.build() +local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true)) +local samples = Circuit.samples +local n = #samples + +local rp = RaycastParams.new() +rp.FilterType = Enum.RaycastFilterType.Include +rp.FilterDescendantsInstances = { map } + +local out = {} +-- Road-only params: what the driver actually touches. +local roadOnly = RaycastParams.new() +roadOnly.FilterType = Enum.RaycastFilterType.Include +roadOnly.FilterDescendantsInstances = { map:FindFirstChild("Expressway") } + +local worstAll, worstRoad, atAll, atRoad = 0, 0, -1, -1 +local hits = {} +for i = 1, n do + local j = (i % n) + 1 + local p = samples[j].pos + local fwd = (samples[(j % n) + 1].pos - samples[i].pos) + fwd = Vector3.new(fwd.X, 0, fwd.Z) + if fwd.Magnitude > 0.01 then + fwd = fwd.Unit + local o1 = p - fwd * 3 + Vector3.new(0, 40, 0) + local o2 = p + fwd * 3 + Vector3.new(0, 40, 0) + local a1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), rp) + local a2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), rp) + if a1 and a2 then + local d = math.abs(a1.Position.Y - a2.Position.Y) + if d > worstAll then + worstAll, atAll = d, i + end + if d > 1 then + hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format( + i, samples[i].piece, d, a1.Instance.Name, a2.Instance.Name) + end + end + local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly) + local b2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), roadOnly) + if b1 and b2 then + local d = math.abs(b1.Position.Y - b2.Position.Y) + if d > worstRoad then + worstRoad, atRoad = d, i + end + end + end +end + +out[#out + 1] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll) +out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format( + worstRoad, atRoad, worstRoad <= 0.60 and "PASS" or "FAIL") +out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits) +for k = 1, math.min(12, #hits) do + out[#out + 1] = " " .. hits[k] +end + +map:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/push_mapbuilder.py b/tools/push_mapbuilder.py new file mode 100644 index 0000000..93bc33a --- /dev/null +++ b/tools/push_mapbuilder.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Push MapBuilder.luau source into a LIVE (Play-mode) Studio via the MCP bridge. + +Rojo cannot reach the running server datamodel in Play mode, so the source is +shipped as ~9000-char chunks into named StringValue children (p0, p1, ...) under +ReplicatedStorage, then concatenated and assigned to the ModuleScript's .Source. + +Named children matter: a retried chunk overwrites its slot instead of appending +a duplicate. Bridge calls fail intermittently ("Unable to find an active Studio +instance") even while Studio is up, so every call is retried. +""" +import sys, time + +sys.path.insert(0, "/tmp") +import rbx + +SRC_PATH = "/home/jesse/roblox/tokyo-wangan-racing/src/server/MapBuilder.luau" +MODULE = 'game:GetService("ServerScriptService"):FindFirstChild("Server", true)' +CHUNK = 9000 + + +def call(code, tries=4): + last = None + for a in range(tries): + try: + r = rbx.call( + "execute_luau", + {"code": code, "datamodel_type": "Server"}, + warmup=6, + wait=45, + timeout=300, + ) + if r and "Unable to find an active Studio" not in str(r): + return r + last = r + except Exception as e: # noqa: BLE001 + last = e + time.sleep(5) + raise RuntimeError(f"bridge failed after {tries} tries: {last}") + + +src = open(SRC_PATH, encoding="utf-8").read() +nbytes = len(src.encode()) +print(f"source: {len(src)} chars, {nbytes} utf-8 bytes") + +# Pick a long-bracket level that does not occur in the source. +lvl = 0 +while ("]" + "=" * lvl + "]") in src or ("[" + "=" * lvl + "[") in src: + lvl += 1 +eq = "=" * lvl +print(f"long-bracket level: {lvl}") + +chunks = [src[i : i + CHUNK] for i in range(0, len(src), CHUNK)] +print(f"chunks: {len(chunks)}") + +print(call('local rs=game:GetService("ReplicatedStorage")\n' + 'local b=rs:FindFirstChild("MBBuf") if b then b:Destroy() end\n' + 'b=Instance.new("Folder") b.Name="MBBuf" b.Parent=rs\n' + 'return "buffer reset"')) + +for i, c in enumerate(chunks): + code = ( + 'local rs=game:GetService("ReplicatedStorage")\n' + 'local b=rs:WaitForChild("MBBuf")\n' + f'local n="p{i}"\n' + "local sv=b:FindFirstChild(n) or Instance.new(\"StringValue\")\n" + "sv.Name=n sv.Parent=b\n" + f"sv.Value=[{eq}[{c}]{eq}]\n" + f'return "p{i} "..#sv.Value' + ) + print(f" chunk {i+1}/{len(chunks)}: {call(code)}") + +assemble = f""" +local rs=game:GetService("ReplicatedStorage") +local b=rs:WaitForChild("MBBuf") +local parts={{}} +for i=0,{len(chunks)-1} do + local sv=b:FindFirstChild("p"..i) + if not sv then return "MISSING CHUNK p"..i end + parts[#parts+1]=sv.Value +end +local s=table.concat(parts) +local sss=game:GetService("ServerScriptService") +local m=sss:FindFirstChild("MapBuilder", true) +if not m then return "MapBuilder ModuleScript NOT FOUND" end +m.Source=s +return ("assembled=%d expected={nbytes} match=%s class=%s path=%s"):format( + #s, tostring(#s=={nbytes}), m.ClassName, m:GetFullName()) +""" +print(call(assemble)) diff --git a/tools/verify_c1_fixes.luau b/tools/verify_c1_fixes.luau new file mode 100644 index 0000000..3660bb1 --- /dev/null +++ b/tools/verify_c1_fixes.luau @@ -0,0 +1,164 @@ +-- Headless verification of the four C1 fidelity fixes. +-- Asserts NUMBERS the build produced, not "the build ran". +local sss = game:GetService("ServerScriptService") +local orig = sss:FindFirstChild("MapBuilder", true) +if not orig then return "FAIL: MapBuilder not found" end + +-- Freshness gate: prove we are measuring the NEW source before trusting anything. +local fresh = {} +for _, tok in ipairs({ "WIDE_ROAD_WIDTH", "BridgeGirder", "GinzaOverpass", "isJctSample", "GIRDER_H" }) do + fresh[#fresh + 1] = tok .. "=" .. tostring(orig.Source:find(tok, 1, true) ~= nil) +end + +-- Fresh-require so Studio's module cache cannot serve a stale copy. +local clone = orig:Clone() +clone.Name = "MapBuilder_Verify" +clone.Parent = orig.Parent +local okM, MB = pcall(require, clone) +if not okM then + clone:Destroy() + return "FAIL require: " .. tostring(MB) +end + +local okB, map = pcall(MB.build) +if not okB then + clone:Destroy() + return "FAIL build: " .. tostring(map) +end + +local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true)) + +local counts = {} +local decks = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + counts[d.Name] = (counts[d.Name] or 0) + 1 + local idx = d.Name:match("^Road_(%d+)$") + if idx then + decks[tonumber(idx)] = d + end + end +end + +local out = {} +local function line(s) + out[#out + 1] = s +end +line("FRESHNESS: " .. table.concat(fresh, " ")) + +-- FIX 1: bridge flag wired up ------------------------------------------------ +local nBridgeSamples = 0 +for _, s in ipairs(Circuit.samples) do + if s.bridge then + nBridgeSamples += 1 + end +end +local girders = counts["BridgeGirder"] or 0 +line(("FIX1 bridge flag: samples=%d girders=%d piercaps=%d piercols=%d -> %s"):format( + nBridgeSamples, girders, counts["PierCap"] or 0, counts["PierColumn"] or 0, + (girders >= nBridgeSamples * 0.5 and girders > 0) and "PASS" or "FAIL")) + +-- FIX 2: Ginza overpasses with piers between the carriageways ---------------- +local ov, pier = counts["GinzaOverpass"] or 0, counts["OverpassPier"] or 0 +line(("FIX2 Ginza 跨道橋: overpass=%d pier=%d parapet=%d -> %s"):format( + ov, pier, counts["OverpassParapet"] or 0, (ov > 0 and pier == ov) and "PASS" or "FAIL")) + +-- Piers must actually sit BETWEEN the carriageways (near the centreline), +-- not off to one side -- that is the whole point of the fix. +local worstPier = 0 +for _, d in ipairs(map:GetDescendants()) do + if d.Name == "OverpassPier" then + local best = math.huge + for i, s in ipairs(Circuit.samples) do + if s.piece == "Ginza" then + local dd = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude + best = math.min(best, dd) + local _ = i + end + end + worstPier = math.max(worstPier, best) + end +end +line(("FIX2 pier centreline offset: worst=%.2f studs -> %s"):format( + worstPier, worstPier < 6 and "PASS" or "FAIL")) + +-- FIX 3: 40 km/h plates in junctions ---------------------------------------- +local n40, n50 = 0, 0 +for _, d in ipairs(map:GetDescendants()) do + if d.Name == "SpeedLimitSign" then + for _, g in ipairs(d:GetDescendants()) do + if g:IsA("TextLabel") then + if g.Text == "40" then + n40 += 1 + elseif g.Text == "50" then + n50 += 1 + end + break + end + end + end +end +line(("FIX3 speed plates: 40kmh=%d 50kmh=%d -> %s"):format( + n40, n50, (n40 > 0 and n50 > 0) and "PASS" or "FAIL")) + +-- FIX 4: Shiodome 3+3 widening ---------------------------------------------- +local shioW, baseW, shioN, baseN = 0, 0, 0, 0 +for i, s in ipairs(Circuit.samples) do + local d = decks[i] + if d then + if s.piece == "Shiodome" then + shioW += d.Size.X + shioN += 1 + elseif s.piece == "Kasumigaseki" then + baseW += d.Size.X + baseN += 1 + end + end +end +shioW = shioN > 0 and shioW / shioN or 0 +baseW = baseN > 0 and baseW / baseN or 0 +line(("FIX4 deck width: Shiodome avg=%.1f Kasumigaseki avg=%.1f -> %s"):format( + shioW, baseW, (shioW > baseW + 8) and "PASS" or "FAIL")) + +-- Lane boundary count: 3 lanes per side needs 2 boundary lines per side. +local function boundariesNear(zone) + local best, bestI = math.huge, nil + for i, s in ipairs(Circuit.samples) do + if s.piece == zone then + bestI = bestI or i + best = 0 + end + end + local _ = best + return bestI +end +local si = boundariesNear("Shiodome") +local nLines = 0 +if si and decks[si] then + local c = decks[si].Position + for _, d in ipairs(map:GetDescendants()) do + if (d.Name == "LaneLine" or d.Name == "Dash") and (d.Position - c).Magnitude < 40 then + nLines += 1 + end + end +end +line(("FIX4 lane boundaries near Shiodome sample %s: %d markings"):format(tostring(si), nLines)) + +-- REGRESSION: no holes in the deck, seam lips still within tolerance -------- +local holes, maxLip = 0, 0 +local n = #Circuit.samples +for i = 1, n do + local a, b = decks[i], decks[(i % n) + 1] + if a and b then + local lip = math.abs((a.Position.Y + a.Size.Y / 2) - (b.Position.Y + b.Size.Y / 2)) + maxLip = math.max(maxLip, lip) + elseif not a then + holes += 1 + end +end +line(("REGRESSION decks=%d holes=%d maxSeamLip=%.3f -> %s"):format( + n, holes, maxLip, (holes == 0 and maxLip <= 0.30) and "PASS" or "FAIL")) + +map:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/verify_c1_fixes2.luau b/tools/verify_c1_fixes2.luau new file mode 100644 index 0000000..1ebae51 --- /dev/null +++ b/tools/verify_c1_fixes2.luau @@ -0,0 +1,144 @@ +-- Verification v2: corrected geometric metrics. +-- +-- v1 had two badly-written assertions (the CODE was fine, the TEST was wrong): +-- * seam lip used Position.Y + Size.Y/2, but decks are pitched by +-- CFrame.lookAt(mid, mid+dir, UP), so that is the centre height, not the +-- surface height at the seam. On a sloped segment it over-reports badly. +-- Correct: evaluate each deck's TOP SURFACE at the shared sample point. +-- * pier offset measured distance to the nearest sample, but piers sit at +-- segment MIDPOINTS and samples are 60 studs apart -- so ~30.0 is the +-- correct answer for a perfectly centred pier. Correct: perpendicular +-- distance to the centreline polyline. +local sss = game:GetService("ServerScriptService") +local orig = sss:FindFirstChild("MapBuilder", true) +local clone = orig:Clone() +clone.Name = "MapBuilder_Verify2" +clone.Parent = orig.Parent +local okM, MB = pcall(require, clone) +if not okM then + clone:Destroy() + return "FAIL require: " .. tostring(MB) +end +local okB, map = pcall(MB.build) +if not okB then + clone:Destroy() + return "FAIL build: " .. tostring(map) +end + +local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true)) +local samples = Circuit.samples +local n = #samples + +local decks = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local idx = d.Name:match("^Road_(%d+)$") + if idx then + decks[tonumber(idx)] = d + end + end +end + +local out = {} +local function line(s) + out[#out + 1] = s +end + +-- TRUE SEAM LIP ------------------------------------------------------------- +-- Deck i spans local z in [-Size.Z/2, +Size.Z/2] and is 2 studs longer than +-- its segment (deliberate overlap), so the shared sample point sits at +-- local z = +(Size.Z - 2)/2 for deck i and -(Size.Z - 2)/2 for deck i+1. +local worst, worstAt = 0, -1 +local lips = {} +for i = 1, n do + local j = (i % n) + 1 + local a, b = decks[i], decks[j] + if a and b then + local ta = (a.CFrame * CFrame.new(0, a.Size.Y / 2, (a.Size.Z - 2) / 2)).Y + local tb = (b.CFrame * CFrame.new(0, b.Size.Y / 2, -(b.Size.Z - 2) / 2)).Y + local lip = math.abs(ta - tb) + lips[#lips + 1] = { lip, i } + if lip > worst then + worst, worstAt = lip, i + end + end +end +table.sort(lips, function(p, q) + return p[1] > q[1] +end) +local top = {} +for k = 1, math.min(5, #lips) do + top[#top + 1] = ("i=%d:%.4f(%s)"):format(lips[k][2], lips[k][1], samples[lips[k][2]].piece) +end +line(("SEAM true max lip=%.4f at i=%d -> %s"):format(worst, worstAt, worst <= 0.30 and "PASS" or "FAIL")) +line("SEAM top5: " .. table.concat(top, " ")) + +-- Deck-width continuity: the taper must not jump more than a few studs +-- between neighbours, or the widening would look like a step in the road. +local maxJump, jumpAt = 0, -1 +for i = 1, n do + local j = (i % n) + 1 + if decks[i] and decks[j] then + local dw = math.abs(decks[i].Size.X - decks[j].Size.X) + if dw > maxJump then + maxJump, jumpAt = dw, i + end + end +end +line(("TAPER max width step=%.2f studs at i=%d -> %s"):format( + maxJump, jumpAt, maxJump <= 6 and "PASS" or "FAIL")) + +-- PIER LATERAL OFFSET ------------------------------------------------------- +-- Perpendicular distance from each pier to the centreline polyline (XZ). +local function segDist(p, u, v) + local pu = Vector3.new(p.X - u.X, 0, p.Z - u.Z) + local uv = Vector3.new(v.X - u.X, 0, v.Z - u.Z) + local L2 = uv:Dot(uv) + if L2 < 1e-6 then + return pu.Magnitude + end + local t = math.clamp(pu:Dot(uv) / L2, 0, 1) + return (pu - uv * t).Magnitude +end + +local worstPier, nPier = 0, 0 +for _, d in ipairs(map:GetDescendants()) do + if d.Name == "OverpassPier" then + nPier += 1 + local best = math.huge + for i = 1, n do + local j = (i % n) + 1 + best = math.min(best, segDist(d.Position, samples[i].pos, samples[j].pos)) + end + worstPier = math.max(worstPier, best) + end +end +line(("PIER lateral offset from centreline: n=%d worst=%.3f studs -> %s"):format( + nPier, worstPier, (nPier > 0 and worstPier < 3) and "PASS" or "FAIL")) + +-- Girders must hang BELOW their deck, never poke through the driving surface. +local bad, nG = 0, 0 +for _, d in ipairs(map:GetDescendants()) do + if d.Name == "BridgeGirder" then + nG += 1 + local topY = d.Position.Y + d.Size.Y / 2 + local best, bi = math.huge, nil + for i = 1, n do + local dd = (Vector3.new(samples[i].pos.X, 0, samples[i].pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude + if dd < best then + best, bi = dd, i + end + end + if bi and decks[bi] then + local deckBottom = decks[bi].Position.Y - decks[bi].Size.Y / 2 + if topY > deckBottom + 0.6 then + bad += 1 + end + end + end +end +line(("GIRDER below deck: n=%d intruding=%d -> %s"):format(nG, bad, bad == 0 and "PASS" or "FAIL")) + +map:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/verify_c1_fixes3.luau b/tools/verify_c1_fixes3.luau new file mode 100644 index 0000000..5648d4d --- /dev/null +++ b/tools/verify_c1_fixes3.luau @@ -0,0 +1,120 @@ +-- Verification v3: seam lip measured against the KNOWN shared sample point. +-- +-- v2 still had a sign bug: Roblox CFrame.LookVector is the -Z axis, so a deck +-- built with CFrame.lookAt(mid, mid+dir) has its FORWARD end at local +-- z = -(Size.Z-2)/2 and its REAR end at +(Size.Z-2)/2. v2 had these swapped, +-- comparing deck i's rear to deck i+1's rear -- a systematic error that +-- reported a near-constant 5.2 on every sloped segment regardless of zone. +-- +-- This version avoids guessing signs: for each deck it takes BOTH end faces, +-- picks whichever is nearer the shared sample point b.pos in XZ, and reports +-- the surface-height difference there. Sanity-checked against the flat-ground +-- expectation and cross-checked with a downward raycast. +local sss = game:GetService("ServerScriptService") +local orig = sss:FindFirstChild("MapBuilder", true) +local clone = orig:Clone() +clone.Name = "MapBuilder_Verify3" +clone.Parent = orig.Parent +local okM, MB = pcall(require, clone) +if not okM then + clone:Destroy() + return "FAIL require: " .. tostring(MB) +end +local okB, map = pcall(MB.build) +if not okB then + clone:Destroy() + return "FAIL build: " .. tostring(map) +end + +local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true)) +local samples = Circuit.samples +local n = #samples + +local decks = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local idx = d.Name:match("^Road_(%d+)$") + if idx then + decks[tonumber(idx)] = d + end + end +end + +local out = {} +local function line(s) + out[#out + 1] = s +end + +-- Top-surface height of deck `d` at the end nearest world point `p` (XZ). +local function topAtNearestEnd(d, p) + local half = (d.Size.Z - 2) / 2 + local best, bestD = nil, math.huge + for _, z in ipairs({ -half, half }) do + local w = d.CFrame * CFrame.new(0, d.Size.Y / 2, z) + local dd = (Vector3.new(w.X, 0, w.Z) - Vector3.new(p.X, 0, p.Z)).Magnitude + if dd < bestD then + best, bestD = w.Y, dd + end + end + return best, bestD +end + +local worst, worstAt, worstGap = 0, -1, 0 +local lips = {} +for i = 1, n do + local j = (i % n) + 1 + local a, b = decks[i], decks[j] + if a and b then + local shared = samples[j].pos -- the sample both decks meet at + local ya, da = topAtNearestEnd(a, shared) + local yb, db = topAtNearestEnd(b, shared) + local lip = math.abs(ya - yb) + lips[#lips + 1] = { lip, i, math.max(da, db) } + if lip > worst then + worst, worstAt, worstGap = lip, i, math.max(da, db) + end + end +end +table.sort(lips, function(p, q) + return p[1] > q[1] +end) +local top = {} +for k = 1, math.min(5, #lips) do + top[#top + 1] = ("i=%d:%.4f(%s)"):format(lips[k][2], lips[k][1], samples[lips[k][2]].piece) +end +line(("SEAM max lip=%.4f at i=%d (endpoint XZ err %.3f) -> %s"):format( + worst, worstAt, worstGap, worst <= 0.30 and "PASS" or "FAIL")) +line("SEAM top5: " .. table.concat(top, " ")) + +-- Independent cross-check: raycast down onto the driving surface just before +-- and just after each seam. A real step would show up here too. +-- MUST filter to the Expressway folder only. Filtering on the whole map lets +-- the ray hit TunnelCeiling / MedianFence / overpass decks on the way down and +-- reports a bogus ~19-stud "step" that has nothing to do with the road surface. +local rp = RaycastParams.new() +rp.FilterType = Enum.RaycastFilterType.Include +rp.FilterDescendantsInstances = { map:FindFirstChild("Expressway") } +local rWorst, rAt = 0, -1 +for i = 1, n do + local j = (i % n) + 1 + local p = samples[j].pos + local fwd = (samples[(j % n) + 1].pos - samples[i].pos) + fwd = Vector3.new(fwd.X, 0, fwd.Z) + if fwd.Magnitude > 0.01 then + fwd = fwd.Unit + local h1 = workspace:Raycast(p - fwd * 3 + Vector3.new(0, 40, 0), Vector3.new(0, -120, 0), rp) + local h2 = workspace:Raycast(p + fwd * 3 + Vector3.new(0, 40, 0), Vector3.new(0, -120, 0), rp) + if h1 and h2 then + local d = math.abs(h1.Position.Y - h2.Position.Y) + if d > rWorst then + rWorst, rAt = d, i + end + end + end +end +line(("RAYCAST surface step across seams (+-3 studs): max=%.4f at i=%d -> %s"):format( + rWorst, rAt, rWorst <= 0.60 and "PASS" or "FAIL")) + +map:Destroy() +clone:Destroy() +return table.concat(out, "\n")